feat: Add-to-Plan flows C4/C5/C6 — recipe picker, quick actions, day picker #44
@@ -166,13 +166,43 @@ public class PlanningService {
|
|||||||
|
|
||||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.recipeapp.planning.dto;
|
||||||
|
|
||||||
|
public record VarietyPreviewResponse(
|
||||||
|
double currentScore,
|
||||||
|
double projectedScore,
|
||||||
|
double scoreDelta
|
||||||
|
) {}
|
||||||
@@ -443,4 +443,93 @@ class PlanningServiceTest {
|
|||||||
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START))
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
95
frontend/src/lib/components/BottomSheet.svelte
Normal file
95
frontend/src/lib/components/BottomSheet.svelte
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = false,
|
||||||
|
onclose,
|
||||||
|
height = '75vh',
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onclose: () => void;
|
||||||
|
height?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
data-testid="bottom-sheet"
|
||||||
|
aria-hidden={open ? 'false' : 'true'}
|
||||||
|
class="fixed inset-0 z-50 flex items-end"
|
||||||
|
>
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
data-testid="sheet-backdrop"
|
||||||
|
class="absolute inset-0"
|
||||||
|
style="background: rgba(28,28,24,0.4);"
|
||||||
|
onclick={onclose}
|
||||||
|
role="presentation"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Sheet panel -->
|
||||||
|
<div
|
||||||
|
class="relative z-10 w-full flex flex-col overflow-hidden"
|
||||||
|
style="
|
||||||
|
background: var(--color-page);
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
box-shadow: var(--shadow-overlay);
|
||||||
|
max-height: {height};
|
||||||
|
"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<!-- Header row: drag handle + close button -->
|
||||||
|
<div class="relative flex items-center justify-center pt-3 pb-2 px-4">
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div
|
||||||
|
data-testid="drag-handle"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute"
|
||||||
|
style="
|
||||||
|
width: 32px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
class="ml-auto text-xl leading-none"
|
||||||
|
onclick={onclose}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body content -->
|
||||||
|
<div class="overflow-y-auto flex-1">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
52
frontend/src/lib/components/BottomSheet.test.ts
Normal file
52
frontend/src/lib/components/BottomSheet.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import BottomSheet from './BottomSheet.svelte';
|
||||||
|
|
||||||
|
describe('BottomSheet', () => {
|
||||||
|
it('is not mounted in DOM when open is false', () => {
|
||||||
|
render(BottomSheet, { props: { open: false, onclose: vi.fn() } });
|
||||||
|
expect(screen.queryByTestId('bottom-sheet')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is mounted in DOM when open is true', () => {
|
||||||
|
render(BottomSheet, { props: { open: true, onclose: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('bottom-sheet')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclose when close button is clicked', async () => {
|
||||||
|
const onclose = vi.fn();
|
||||||
|
render(BottomSheet, { props: { open: true, onclose } });
|
||||||
|
const closeBtn = screen.getByRole('button', { name: /schließen/i });
|
||||||
|
await userEvent.click(closeBtn);
|
||||||
|
expect(onclose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclose when backdrop is clicked', async () => {
|
||||||
|
const onclose = vi.fn();
|
||||||
|
render(BottomSheet, { props: { open: true, onclose } });
|
||||||
|
const backdrop = screen.getByTestId('sheet-backdrop');
|
||||||
|
await userEvent.click(backdrop);
|
||||||
|
expect(onclose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclose when Escape is pressed', async () => {
|
||||||
|
const onclose = vi.fn();
|
||||||
|
render(BottomSheet, { props: { open: true, onclose } });
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
expect(onclose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drag handle has aria-hidden', () => {
|
||||||
|
render(BottomSheet, { props: { open: true, onclose: vi.fn() } });
|
||||||
|
const handle = screen.getByTestId('drag-handle');
|
||||||
|
expect(handle.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onclose when Escape is pressed while closed', async () => {
|
||||||
|
const onclose = vi.fn();
|
||||||
|
render(BottomSheet, { props: { open: false, onclose } });
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
expect(onclose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,12 +16,14 @@
|
|||||||
slot,
|
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>
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { weekDays, prevWeek, nextWeek, formatDayAbbr, formatWeekRange } from './week';
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
id: string;
|
||||||
|
slotDate: string;
|
||||||
|
recipe: { id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
recipeName,
|
||||||
|
recipeId,
|
||||||
|
planId,
|
||||||
|
weekStart,
|
||||||
|
today,
|
||||||
|
slots = [],
|
||||||
|
onconfirm,
|
||||||
|
onweekchange
|
||||||
|
}: {
|
||||||
|
recipeName: string;
|
||||||
|
recipeId: string;
|
||||||
|
planId: string;
|
||||||
|
weekStart: string;
|
||||||
|
today: string;
|
||||||
|
slots: Slot[];
|
||||||
|
onconfirm: (result: { date: string; slotId: string | null }) => void;
|
||||||
|
onweekchange: (newWeekStart: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let selectedDate = $state<string | null>(null);
|
||||||
|
|
||||||
|
const slotMap = $derived(
|
||||||
|
new Map(slots.map((s) => [s.slotDate, s]))
|
||||||
|
);
|
||||||
|
|
||||||
|
const days = $derived(weekDays(weekStart));
|
||||||
|
|
||||||
|
function chipState(date: string): string {
|
||||||
|
const isSelected = selectedDate === date;
|
||||||
|
const slot = slotMap.get(date);
|
||||||
|
const hasFilled = slot?.recipe != null;
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
return hasFilled ? 'sel-filled' : 'sel-empty';
|
||||||
|
}
|
||||||
|
if (date === today) return 'today';
|
||||||
|
return hasFilled ? 'filled' : 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSlot = $derived(selectedDate ? slotMap.get(selectedDate) : undefined);
|
||||||
|
const existingRecipeName = $derived(selectedSlot?.recipe?.name ?? null);
|
||||||
|
const existingSlotId = $derived(selectedSlot?.id ?? null);
|
||||||
|
|
||||||
|
function chipStyle(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'empty':
|
||||||
|
return 'border-style: dashed; border-color: var(--green-light); background: var(--green-tint);';
|
||||||
|
case 'filled':
|
||||||
|
return 'border-color: var(--color-border); background: var(--color-surface);';
|
||||||
|
case 'today':
|
||||||
|
return 'border-color: var(--yellow); background: var(--yellow-tint);';
|
||||||
|
case 'sel-empty':
|
||||||
|
return 'border: 2px solid var(--green-dark); background: var(--green-tint);';
|
||||||
|
case 'sel-filled':
|
||||||
|
return 'border: 2px solid var(--orange-dark); background: var(--orange-tint);';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChipClick(date: string) {
|
||||||
|
selectedDate = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!selectedDate) return;
|
||||||
|
onconfirm({ date: selectedDate, slotId: existingSlotId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayNumber(date: string): string {
|
||||||
|
return date.slice(-2).replace(/^0/, '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||||
|
<p
|
||||||
|
style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;"
|
||||||
|
>
|
||||||
|
Tag wählen
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||||
|
{recipeName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Week navigation -->
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--color-border);"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Vorherige Woche"
|
||||||
|
onclick={() => onweekchange(prevWeek(weekStart))}
|
||||||
|
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span style="font-size: 12px; font-weight: 500; color: var(--color-text);">
|
||||||
|
{formatWeekRange(weekStart)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Nächste Woche"
|
||||||
|
onclick={() => onweekchange(nextWeek(weekStart))}
|
||||||
|
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day chips -->
|
||||||
|
<div
|
||||||
|
style="display: flex; gap: 6px; padding: 10px 12px; overflow-x: auto;"
|
||||||
|
>
|
||||||
|
{#each days as date (date)}
|
||||||
|
{@const state = chipState(date)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="chip-{date}"
|
||||||
|
data-state={state}
|
||||||
|
onclick={() => handleChipClick(date)}
|
||||||
|
style="flex: 1; min-width: 36px; padding: 6px 4px; border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; text-align: center; font-family: var(--font-sans); {chipStyle(state)}"
|
||||||
|
>
|
||||||
|
<span style="display: block; font-size: 9px; font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em;">
|
||||||
|
{formatDayAbbr(date, 'narrow')}
|
||||||
|
</span>
|
||||||
|
<span style="display: block; font-size: 13px; font-weight: 600; color: var(--color-text); margin-top: 2px;">
|
||||||
|
{dayNumber(date)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replace warning -->
|
||||||
|
{#if selectedDate && existingRecipeName}
|
||||||
|
<div
|
||||||
|
data-testid="replace-warning"
|
||||||
|
style="margin: 0 12px 10px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--orange-tint); border: 1px solid var(--orange-dark); font-size: 11px; color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Ersetzt <strong>{existingRecipeName}</strong> an diesem Tag.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Confirm button -->
|
||||||
|
<div style="padding: 0 12px 12px;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="confirm-btn"
|
||||||
|
disabled={!selectedDate}
|
||||||
|
onclick={handleConfirm}
|
||||||
|
style="width: 100%; padding: 9px 12px; font-family: var(--font-sans); font-size: 13px; font-weight: 600; border-radius: var(--radius-md); border: none; cursor: {selectedDate ? 'pointer' : 'not-allowed'}; background: {selectedDate ? 'var(--green)' : 'var(--color-border)'}; color: {selectedDate ? '#fff' : 'var(--color-text-muted)'};"
|
||||||
|
>
|
||||||
|
Einplanen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import DayPicker from './DayPicker.svelte';
|
||||||
|
|
||||||
|
const weekStart = '2026-03-30'; // Monday
|
||||||
|
const today = '2026-04-01'; // Wednesday
|
||||||
|
|
||||||
|
// Mo: filled, Di: filled (today), Mi: filled, Do: empty, Fr: filled, Sa: empty, So: filled
|
||||||
|
const slots = [
|
||||||
|
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } },
|
||||||
|
{ id: 's2', slotDate: '2026-04-01', recipe: { id: 'r2', name: 'Curry', effort: 'easy' } },
|
||||||
|
{ id: 's3', slotDate: '2026-04-02', recipe: { id: 'r3', name: 'Risotto', effort: 'medium' } },
|
||||||
|
{ id: 's5', slotDate: '2026-04-04', recipe: { id: 'r5', name: 'Suppe', effort: 'easy' } },
|
||||||
|
{ id: 's7', slotDate: '2026-04-06', recipe: { id: 'r7', name: 'Stir Fry', effort: 'easy' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
recipeName: 'Mushroom Risotto',
|
||||||
|
recipeId: 'recipe-42',
|
||||||
|
planId: 'plan-1',
|
||||||
|
weekStart,
|
||||||
|
today,
|
||||||
|
slots,
|
||||||
|
onconfirm: vi.fn(),
|
||||||
|
onweekchange: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DayPicker', () => {
|
||||||
|
it('shows recipe name in header', () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
expect(screen.getByText('Mushroom Risotto')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows 7 day chips', () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const chips = screen.getAllByTestId(/^chip-/);
|
||||||
|
expect(chips).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks empty slot chips with data-state="empty"', () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
// Do (2026-04-03) and Sa (2026-04-05) are empty
|
||||||
|
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||||
|
expect(doChip.getAttribute('data-state')).toBe('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks filled slot chips with data-state="filled"', () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||||
|
expect(moChip.getAttribute('data-state')).toBe('filled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks today chip with data-state="today"', () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const todayChip = screen.getByTestId('chip-2026-04-01');
|
||||||
|
expect(todayChip.getAttribute('data-state')).toBe('today');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selecting an empty chip changes its state to sel-empty', async () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||||
|
await userEvent.click(doChip);
|
||||||
|
expect(doChip.getAttribute('data-state')).toBe('sel-empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selecting a filled chip changes its state to sel-filled', async () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||||
|
await userEvent.click(moChip);
|
||||||
|
expect(moChip.getAttribute('data-state')).toBe('sel-filled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows replace warning when filled chip is selected', async () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||||
|
await userEvent.click(moChip);
|
||||||
|
expect(screen.getByTestId('replace-warning')).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Pasta/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show replace warning when empty chip is selected', async () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||||
|
await userEvent.click(doChip);
|
||||||
|
expect(screen.queryByTestId('replace-warning')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm button is disabled when no chip is selected', () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
const btn = screen.getByTestId('confirm-btn');
|
||||||
|
expect(btn.hasAttribute('disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onconfirm with date and null slotId when empty chip confirmed', async () => {
|
||||||
|
const onconfirm = vi.fn();
|
||||||
|
render(DayPicker, { props: { ...baseProps, onconfirm } });
|
||||||
|
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||||
|
await userEvent.click(doChip);
|
||||||
|
const btn = screen.getByTestId('confirm-btn');
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-04-03', slotId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onconfirm with date and slotId when filled chip confirmed', async () => {
|
||||||
|
const onconfirm = vi.fn();
|
||||||
|
render(DayPicker, { props: { ...baseProps, onconfirm } });
|
||||||
|
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||||
|
await userEvent.click(moChip);
|
||||||
|
const btn = screen.getByTestId('confirm-btn');
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-03-30', slotId: 's1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows prev/next week navigation buttons', () => {
|
||||||
|
render(DayPicker, { props: baseProps });
|
||||||
|
expect(screen.getByRole('button', { name: /Vorherige Woche/ })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /Nächste Woche/ })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onweekchange with prev week when prev button clicked', async () => {
|
||||||
|
const onweekchange = vi.fn();
|
||||||
|
render(DayPicker, { props: { ...baseProps, onweekchange } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Vorherige Woche/ }));
|
||||||
|
expect(onweekchange).toHaveBeenCalledWith('2026-03-23');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onweekchange with next week when next button clicked', async () => {
|
||||||
|
const onweekchange = vi.fn();
|
||||||
|
render(DayPicker, { props: { ...baseProps, onweekchange } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Nächste Woche/ }));
|
||||||
|
expect(onweekchange).toHaveBeenCalledWith('2026-04-06');
|
||||||
|
});
|
||||||
|
});
|
||||||
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Recipe {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
effort?: string;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
recipe: Recipe;
|
||||||
|
simulatedScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
planId,
|
||||||
|
date,
|
||||||
|
dateLabel,
|
||||||
|
currentVarietyScore = 0,
|
||||||
|
suggestions = [],
|
||||||
|
allRecipes = [],
|
||||||
|
onpick
|
||||||
|
}: {
|
||||||
|
planId: string;
|
||||||
|
date: string;
|
||||||
|
dateLabel: string;
|
||||||
|
currentVarietyScore?: number;
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
allRecipes: Recipe[];
|
||||||
|
onpick: (recipeId: string, recipeName: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
|
let filteredRecipes = $derived(
|
||||||
|
searchQuery.trim() === ''
|
||||||
|
? allRecipes
|
||||||
|
: allRecipes.filter((r) =>
|
||||||
|
r.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function recipeMetadata(recipe: Recipe): string {
|
||||||
|
return [
|
||||||
|
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
|
||||||
|
recipe.effort ?? null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||||
|
<p style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;">
|
||||||
|
Rezept wählen
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||||
|
{dateLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Rezept suchen…"
|
||||||
|
style="width: 100%; box-sizing: border-box; padding: 5px 8px; font-size: 11px; font-family: var(--font-sans); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empfohlen section -->
|
||||||
|
{#if suggestions.length > 0}
|
||||||
|
<div
|
||||||
|
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||||
|
>
|
||||||
|
Empfohlen · Beste Abwechslung
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each suggestions as suggestion (suggestion.recipe.id)}
|
||||||
|
{@const delta = suggestion.simulatedScore - currentVarietyScore}
|
||||||
|
{@const meta = recipeMetadata(suggestion.recipe)}
|
||||||
|
<div
|
||||||
|
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||||
|
>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<p
|
||||||
|
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||||
|
>
|
||||||
|
{suggestion.recipe.name}
|
||||||
|
</p>
|
||||||
|
{#if meta}
|
||||||
|
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||||
|
{meta}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if delta > 0}
|
||||||
|
<span
|
||||||
|
data-testid="badge-{suggestion.recipe.id}"
|
||||||
|
data-type="good"
|
||||||
|
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
||||||
|
>
|
||||||
|
↑ +{delta.toFixed(0)} Punkte
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
data-testid="badge-{suggestion.recipe.id}"
|
||||||
|
data-type="warning"
|
||||||
|
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
|
||||||
|
>
|
||||||
|
⚠ Variationskonflikt
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Wählen"
|
||||||
|
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
|
||||||
|
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
|
||||||
|
>
|
||||||
|
+ Wählen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Alle Rezepte section -->
|
||||||
|
<div
|
||||||
|
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||||
|
>
|
||||||
|
Alle Rezepte
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filteredRecipes.length === 0}
|
||||||
|
<p style="padding: 10px 12px; font-size: 11px; color: var(--color-text-muted); margin: 0;">
|
||||||
|
Keine Treffer
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each filteredRecipes as recipe (recipe.id)}
|
||||||
|
{@const meta = recipeMetadata(recipe)}
|
||||||
|
<div
|
||||||
|
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||||
|
>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<p
|
||||||
|
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||||
|
>
|
||||||
|
{recipe.name}
|
||||||
|
</p>
|
||||||
|
{#if meta}
|
||||||
|
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||||
|
{meta}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Wählen"
|
||||||
|
onclick={() => onpick(recipe.id, recipe.name)}
|
||||||
|
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
|
||||||
|
>
|
||||||
|
+ Wählen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
101
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
101
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import RecipePicker from './RecipePicker.svelte';
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
{ recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, simulatedScore: 9.5 },
|
||||||
|
{ recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, simulatedScore: 6.0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const allRecipes = [
|
||||||
|
{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 },
|
||||||
|
{ id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy', cookTimeMin: 20 },
|
||||||
|
{ id: 'r3', name: 'Tomatensuppe', effort: 'easy', cookTimeMin: 30 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
planId: 'plan-1',
|
||||||
|
date: '2026-04-05',
|
||||||
|
dateLabel: 'Samstag, 5. April',
|
||||||
|
currentVarietyScore: 7.5,
|
||||||
|
suggestions,
|
||||||
|
allRecipes,
|
||||||
|
onpick: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecipePicker', () => {
|
||||||
|
it('shows date label in header', () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
expect(screen.getByText('Samstag, 5. April')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Empfohlen section', () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all suggestion recipe names', () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
expect(screen.getByText('Lachsfilet')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Hähnchen-Curry')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows green badge for suggestions with positive delta', () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
// Lachsfilet: simulatedScore 9.5 - currentVarietyScore 7.5 = +2 → green badge
|
||||||
|
const badge = screen.getByTestId('badge-s1');
|
||||||
|
expect(badge.getAttribute('data-type')).toBe('good');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows yellow badge for suggestions with zero or negative delta', () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
// Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge
|
||||||
|
const badge = screen.getByTestId('badge-s2');
|
||||||
|
expect(badge.getAttribute('data-type')).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Alle Rezepte section', () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
expect(screen.getByText(/Alle Rezepte/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all recipe names in Alle Rezepte', () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
expect(screen.getByText('Beef Bourguignon')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Tomatensuppe')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters recipes by search query', async () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
const input = screen.getByRole('searchbox');
|
||||||
|
await userEvent.type(input, 'Spaghetti');
|
||||||
|
expect(screen.queryByText('Beef Bourguignon')).toBeNull();
|
||||||
|
expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onpick with recipeId and name when Wählen clicked for suggestion', async () => {
|
||||||
|
const onpick = vi.fn();
|
||||||
|
render(RecipePicker, { props: { ...baseProps, onpick } });
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||||
|
await userEvent.click(buttons[0]);
|
||||||
|
expect(onpick).toHaveBeenCalledWith('s1', 'Lachsfilet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onpick when Wählen clicked for all-recipes item', async () => {
|
||||||
|
const onpick = vi.fn();
|
||||||
|
render(RecipePicker, { props: { ...baseProps, onpick } });
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||||
|
// First 2 are suggestions, rest are allRecipes
|
||||||
|
await userEvent.click(buttons[2]);
|
||||||
|
expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when search has no results', async () => {
|
||||||
|
render(RecipePicker, { props: baseProps });
|
||||||
|
const input = screen.getByRole('searchbox');
|
||||||
|
await userEvent.type(input, 'xyznotfound');
|
||||||
|
expect(screen.getByText(/Keine Treffer/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/lib/planner/UndoBar.svelte
Normal file
67
frontend/src/lib/planner/UndoBar.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
visible,
|
||||||
|
message,
|
||||||
|
onundo,
|
||||||
|
ondismiss
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
message: string;
|
||||||
|
onundo: () => void;
|
||||||
|
ondismiss: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
ondismiss();
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
data-testid="undo-bar"
|
||||||
|
role="status"
|
||||||
|
style="
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--color-text);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span style="color: #E8E8E2; font-family: var(--font-sans); font-size: 14px;">
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onundo}
|
||||||
|
style="
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
"
|
||||||
|
onmouseenter={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'underline')}
|
||||||
|
onmouseleave={(e) => ((e.currentTarget as HTMLButtonElement).style.textDecoration = 'none')}
|
||||||
|
>
|
||||||
|
Rückgängig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
56
frontend/src/lib/planner/UndoBar.test.ts
Normal file
56
frontend/src/lib/planner/UndoBar.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, act } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import UndoBar from './UndoBar.svelte';
|
||||||
|
|
||||||
|
describe('UndoBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not mounted when visible is false', () => {
|
||||||
|
render(UndoBar, { props: { visible: false, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.queryByTestId('undo-bar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is mounted and shows message when visible is true', () => {
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Gericht hinzugefügt', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.getByTestId('undo-bar')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Gericht hinzugefügt')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Rückgängig button', () => {
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.getByRole('button', { name: /Rückgängig/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onundo when Rückgängig is clicked', async () => {
|
||||||
|
const onundo = vi.fn();
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo, ondismiss: vi.fn() } });
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i }));
|
||||||
|
expect(onundo).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls ondismiss after 4 seconds', async () => {
|
||||||
|
const ondismiss = vi.fn();
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } });
|
||||||
|
await act(() => { vi.advanceTimersByTime(4000); });
|
||||||
|
expect(ondismiss).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call ondismiss before 4 seconds', async () => {
|
||||||
|
const ondismiss = vi.fn();
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss } });
|
||||||
|
await act(() => { vi.advanceTimersByTime(3999); });
|
||||||
|
expect(ondismiss).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has role="status" for accessibility', () => {
|
||||||
|
render(UndoBar, { props: { visible: true, message: 'Test', onundo: vi.fn(), ondismiss: vi.fn() } });
|
||||||
|
expect(screen.getByRole('status')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts">
|
<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>
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
65
frontend/src/lib/server/slotActions.ts
Normal file
65
frontend/src/lib/server/slotActions.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function isValidUuid(value: string | null): value is string {
|
||||||
|
return typeof value === 'string' && UUID_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string | null;
|
||||||
|
const slotDate = formData.get('slotDate') as string | null;
|
||||||
|
const recipeId = formData.get('recipeId') as string | null;
|
||||||
|
|
||||||
|
if (!isValidUuid(planId) || !isValidUuid(recipeId) || !slotDate) {
|
||||||
|
return { success: false, error: 'Ungültige Eingabe.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
||||||
|
params: { path: { id: planId } },
|
||||||
|
body: { slotDate, recipeId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) return { success: false };
|
||||||
|
return { success: true, slot: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string | null;
|
||||||
|
const slotId = formData.get('slotId') as string | null;
|
||||||
|
const recipeId = formData.get('recipeId') as string | null;
|
||||||
|
|
||||||
|
if (!isValidUuid(planId) || !isValidUuid(slotId) || !isValidUuid(recipeId)) {
|
||||||
|
return { success: false, error: 'Ungültige Eingabe.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||||
|
params: { path: { planId, slotId } },
|
||||||
|
body: { recipeId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) return { success: false };
|
||||||
|
return { success: true, slot: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSlotAction({ fetch, request }: { fetch: typeof globalThis.fetch; request: Request }) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string | null;
|
||||||
|
const slotId = formData.get('slotId') as string | null;
|
||||||
|
|
||||||
|
if (!isValidUuid(planId) || !isValidUuid(slotId)) {
|
||||||
|
return { success: false, error: 'Ungültige Eingabe.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||||
|
params: { path: { planId, slotId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return { success: false };
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -1,32 +1,51 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import 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') {
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
|||||||
24
frontend/src/routes/(app)/planner/+server.ts
Normal file
24
frontend/src/routes/(app)/planner/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
// GET /planner?planId=&date= — returns suggestions JSON for C4 recipe picker
|
||||||
|
export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||||
|
const planId = url.searchParams.get('planId');
|
||||||
|
const date = url.searchParams.get('date');
|
||||||
|
|
||||||
|
if (!planId || !date) {
|
||||||
|
return json({ suggestions: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data } = await api.GET('/v1/week-plans/{id}/suggestions', {
|
||||||
|
params: { path: { id: planId }, query: { slotDate: date } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const suggestions = (data?.suggestions ?? []).sort(
|
||||||
|
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({ suggestions });
|
||||||
|
};
|
||||||
@@ -6,10 +6,28 @@ vi.mock('$env/dynamic/private', () => ({
|
|||||||
|
|
||||||
const mockGet = vi.fn();
|
const 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import { apiClient } from '$lib/server/api';
|
|
||||||
import { getWeekStart } from '$lib/planner/week';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url, locals: _locals }) => {
|
|
||||||
const weekParam = url.searchParams.get('week');
|
|
||||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
|
||||||
const selectedDay = url.searchParams.get('day') ?? weekStart;
|
|
||||||
|
|
||||||
const api = apiClient(fetch);
|
|
||||||
|
|
||||||
// Load the week plan for context (week-so-far display)
|
|
||||||
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
|
|
||||||
params: { query: { weekStart } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (weekPlanError || !weekPlan?.id) {
|
|
||||||
return { weekPlan: null, suggestions: [], selectedDay, weekStart };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load variety-aware suggestions for the selected day
|
|
||||||
const { data: suggestionsData } = await api.GET('/v1/week-plans/{id}/suggestions', {
|
|
||||||
params: { path: { id: weekPlan.id }, query: { slotDate: selectedDay } }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by simulatedScore descending (highest = best variety fit)
|
|
||||||
const suggestions = (suggestionsData?.suggestions ?? []).sort(
|
|
||||||
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { weekPlan, suggestions, selectedDay, weekStart };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
pickSuggestion: async ({ fetch, request, locals }) => {
|
|
||||||
// Role guard: only planners may assign meals
|
|
||||||
if (locals.benutzer?.rolle !== 'planer') {
|
|
||||||
return { success: false, error: 'Keine Berechtigung.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
|
||||||
const planId = formData.get('planId') as string;
|
|
||||||
const recipeId = formData.get('recipeId') as string;
|
|
||||||
const slotDate = formData.get('slotDate') as string;
|
|
||||||
const weekStart = formData.get('weekStart') as string;
|
|
||||||
|
|
||||||
// Validate slotDate format
|
|
||||||
if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) {
|
|
||||||
return { success: false, error: 'Ungültiges Datum.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate planId is non-empty
|
|
||||||
if (!planId) {
|
|
||||||
return { success: false, error: 'Fehlende Plan-ID.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate recipeId is UUID-like format
|
|
||||||
if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) {
|
|
||||||
return { success: false, error: 'Ungültige Rezept-ID.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = apiClient(fetch);
|
|
||||||
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
|
||||||
params: { path: { id: planId } },
|
|
||||||
body: { slotDate, recipeId }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect back to the planner after successful pick (spec: "returns to C1")
|
|
||||||
redirect(303, `/planner?week=${weekStart || slotDate.slice(0, 7) + '-01'}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import SuggestionCard from '$lib/planner/SuggestionCard.svelte';
|
|
||||||
import SuggestionContextBanner from '$lib/planner/SuggestionContextBanner.svelte';
|
|
||||||
import { formatDayLabel } from '$lib/planner/week';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
let weekPlan = $derived(data.weekPlan);
|
|
||||||
let suggestions = $derived(data.suggestions ?? []);
|
|
||||||
let selectedDay = $derived(data.selectedDay);
|
|
||||||
let weekStart = $derived(data.weekStart);
|
|
||||||
|
|
||||||
// Add rank and derive reasoning from simulatedScore for display.
|
|
||||||
// TODO: replace hardcoded threshold (7.5) with API-provided reasoning once backend supports it.
|
|
||||||
let rankedSuggestions = $derived(
|
|
||||||
suggestions.map((s: any, i: number) => ({
|
|
||||||
...s,
|
|
||||||
reasoningType: (s.simulatedScore ?? 0) >= 7.5 ? 'good' : 'warning',
|
|
||||||
reasoningLabel:
|
|
||||||
(s.simulatedScore ?? 0) >= 7.5
|
|
||||||
? 'Passt gut zur Woche'
|
|
||||||
: 'Wiederholung möglich'
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Gerichtsvorschläge — Mealplan</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<!-- Mobile layout: full-width list with context banner -->
|
|
||||||
<div class="flex h-full flex-col lg:hidden">
|
|
||||||
<!-- Mobile topbar -->
|
|
||||||
<header class="sticky top-0 z-10 flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
|
||||||
<a
|
|
||||||
href="/planner?week={weekStart}"
|
|
||||||
aria-label="Zurück zum Planer"
|
|
||||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</a>
|
|
||||||
<h1 class="font-[var(--font-display)] text-[18px] font-[300] text-[var(--color-text)]">
|
|
||||||
Vorschläge für {formatDayLabel(selectedDay)}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Context banner -->
|
|
||||||
<div class="px-4 pt-3">
|
|
||||||
<SuggestionContextBanner {selectedDay} {weekPlan} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Suggestion list -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-6">
|
|
||||||
{#if rankedSuggestions.length === 0}
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
|
||||||
Keine Vorschläge verfügbar.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Rezeptbibliothek durchsuchen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each rankedSuggestions as suggestion, i}
|
|
||||||
<SuggestionCard
|
|
||||||
{suggestion}
|
|
||||||
rank={i + 1}
|
|
||||||
planId={weekPlan?.id ?? ''}
|
|
||||||
slotDate={selectedDay}
|
|
||||||
{weekStart}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browse full library fallback -->
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Rezeptbibliothek durchsuchen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop: 2-panel layout -->
|
|
||||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
|
||||||
<!-- Topbar -->
|
|
||||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
|
||||||
<a
|
|
||||||
href="/planner?week={weekStart}"
|
|
||||||
aria-label="Zurück zum Planer"
|
|
||||||
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</a>
|
|
||||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
|
||||||
Vorschläge für {formatDayLabel(selectedDay)}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
|
||||||
<!-- Left context panel (280px) -->
|
|
||||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-5 overflow-y-auto">
|
|
||||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
|
||||||
Diese Woche bisher
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if weekPlan?.slots?.length}
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{#each (weekPlan.slots ?? []).filter((s: any) => s.slotDate !== selectedDay) as slot}
|
|
||||||
<li class="flex items-baseline gap-2">
|
|
||||||
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
|
||||||
{formatDayLabel(slot.slotDate ?? '').split(',')[0]}
|
|
||||||
</span>
|
|
||||||
{#if slot.recipe}
|
|
||||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
|
||||||
{slot.recipe.name}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">— Nicht geplant</span>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
|
||||||
Noch keine Gerichte diese Woche geplant.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Filter reasons -->
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
|
||||||
Filterkriterien
|
|
||||||
</h3>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
· Keine Zutatenwiederholungen (3 Tage)
|
|
||||||
</li>
|
|
||||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
· Protein-Abwechslung beachten
|
|
||||||
</li>
|
|
||||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
|
||||||
· Aufwandsbalance
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browse library link in desktop panel footer -->
|
|
||||||
<div class="mt-auto pt-6">
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Bibliothek →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Right suggestions panel -->
|
|
||||||
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-6 py-5">
|
|
||||||
{#if rankedSuggestions.length === 0}
|
|
||||||
<div class="flex h-full flex-col items-center justify-center">
|
|
||||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
|
||||||
Keine Vorschläge verfügbar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each rankedSuggestions as suggestion, i}
|
|
||||||
<SuggestionCard
|
|
||||||
{suggestion}
|
|
||||||
rank={i + 1}
|
|
||||||
planId={weekPlan?.id ?? ''}
|
|
||||||
slotDate={selectedDay}
|
|
||||||
{weekStart}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<a
|
|
||||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
|
||||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
|
||||||
>
|
|
||||||
Gesamte Rezeptbibliothek durchsuchen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('$env/dynamic/private', () => ({
|
|
||||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
|
||||||
const mockPost = vi.fn();
|
|
||||||
vi.mock('$lib/server/api', () => ({
|
|
||||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('suggestions page — load', () => {
|
|
||||||
let load: any;
|
|
||||||
|
|
||||||
const mockSuggestions = {
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
|
||||||
simulatedScore: 9.2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
|
||||||
simulatedScore: 6.1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockWeekPlan = {
|
|
||||||
id: 'plan-1',
|
|
||||||
weekStart: '2026-03-30',
|
|
||||||
status: 'draft',
|
|
||||||
slots: [
|
|
||||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
vi.resetModules();
|
|
||||||
const mod = await import('./+page.server');
|
|
||||||
load = mod.load;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches suggestions for the given plan and day', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
|
|
||||||
params: expect.objectContaining({ path: { id: 'plan-1' } })
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns suggestions list sorted by simulatedScore descending', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone');
|
|
||||||
expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the selectedDay from URL params', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.selectedDay).toBe('2026-04-01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty suggestions when API fails', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.suggestions).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns week plan slots for context display', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.weekPlan).toBeDefined();
|
|
||||||
expect(result.weekPlan.slots).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null weekPlan and empty suggestions when week plan not found', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.weekPlan).toBeNull();
|
|
||||||
expect(result.suggestions).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults day to weekStart when no day param provided', async () => {
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
|
||||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
|
||||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30');
|
|
||||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
|
||||||
expect(result.selectedDay).toBe('2026-03-30');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('suggestions page — pickSuggestion action', () => {
|
|
||||||
let actions: any;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockGet.mockReset();
|
|
||||||
mockPost.mockReset();
|
|
||||||
vi.resetModules();
|
|
||||||
const mod = await import('./+page.server');
|
|
||||||
actions = mod.actions;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds a slot to the week plan via POST and redirects to planner', async () => {
|
|
||||||
mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined });
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
try {
|
|
||||||
await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect.unreachable();
|
|
||||||
} catch (e: any) {
|
|
||||||
expect(e.status).toBe(303);
|
|
||||||
expect(e.location).toBe('/planner?week=2026-03-30');
|
|
||||||
}
|
|
||||||
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({
|
|
||||||
params: { path: { id: 'plan-1' } },
|
|
||||||
body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' }
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error when planId is missing', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', '');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error for invalid recipeId format', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', 'not-a-uuid');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error when API fails', async () => {
|
|
||||||
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns permission error for member role', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', '2026-04-01');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error for invalid slotDate format', async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.set('planId', 'plan-1');
|
|
||||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
|
||||||
formData.set('slotDate', 'not-a-date');
|
|
||||||
formData.set('weekStart', '2026-03-30');
|
|
||||||
const result = await actions.pickSuggestion({
|
|
||||||
fetch: vi.fn(),
|
|
||||||
request: { formData: async () => formData },
|
|
||||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
|
||||||
expect(mockPost).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +1,36 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { apiClient } from '$lib/server/api';
|
import { 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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
// GET /recipes?week=YYYY-MM-DD — returns week plan for DayPicker week navigation
|
||||||
|
export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||||
|
const weekStart = url.searchParams.get('week');
|
||||||
|
if (!weekStart) return json({ plan: null });
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.GET('/v1/week-plans', {
|
||||||
|
params: { query: { weekStart } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data?.id) return json({ plan: null });
|
||||||
|
return json({ plan: data });
|
||||||
|
};
|
||||||
@@ -5,10 +5,32 @@ vi.mock('$env/dynamic/private', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
71
frontend/src/routes/(app)/recipes/server.test.ts
Normal file
71
frontend/src/routes/(app)/recipes/server.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ GET: mockGet })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
|
||||||
|
|
||||||
|
const mockWeekPlan = {
|
||||||
|
id: PLAN_UUID,
|
||||||
|
weekStart: '2026-04-07',
|
||||||
|
slots: []
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GET /recipes (server route)', () => {
|
||||||
|
let GET: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+server');
|
||||||
|
GET = mod.GET;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plan data when week param is provided and API succeeds', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined });
|
||||||
|
const url = new URL('http://localhost/recipes?week=2026-04-07');
|
||||||
|
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.plan).toBeDefined();
|
||||||
|
expect(body.plan.id).toBe(PLAN_UUID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { plan: null } when week param is missing', async () => {
|
||||||
|
const url = new URL('http://localhost/recipes');
|
||||||
|
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.plan).toBeNull();
|
||||||
|
expect(mockGet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { plan: null } when API returns an error', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } });
|
||||||
|
const url = new URL('http://localhost/recipes?week=2026-04-07');
|
||||||
|
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.plan).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns { plan: null } when API returns data without id', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { weekStart: '2026-04-07' }, error: undefined });
|
||||||
|
const url = new URL('http://localhost/recipes?week=2026-04-07');
|
||||||
|
const response = await GET({ fetch: vi.fn(), url } as any);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.plan).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls GET /v1/week-plans with the provided week param', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: mockWeekPlan, error: undefined });
|
||||||
|
const url = new URL('http://localhost/recipes?week=2026-04-14');
|
||||||
|
await GET({ fetch: vi.fn(), url } as any);
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({
|
||||||
|
params: { query: { weekStart: '2026-04-14' } }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,35 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
// Import @testing-library/svelte here so its beforeEach (setup/asyncWrapper=act)
|
||||||
|
// is registered before our own beforeEach below.
|
||||||
|
import '@testing-library/svelte';
|
||||||
|
import { configure } from '@testing-library/dom';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// Patch userEvent direct-API methods to use delay:null when fake timers are
|
||||||
|
// active. With delay:null, user-event's internal wait() short-circuits
|
||||||
|
// (typeof null !== 'number') and no setTimeout is scheduled — so clicks and
|
||||||
|
// other interactions work correctly under vi.useFakeTimers().
|
||||||
|
const originalClick = userEvent.click.bind(userEvent);
|
||||||
|
// @ts-expect-error patching direct API
|
||||||
|
userEvent.click = (element: Element, options = {}) => {
|
||||||
|
if (vi.isFakeTimers()) {
|
||||||
|
// @ts-expect-error delay:null is a valid user-event option
|
||||||
|
return originalClick(element, { delay: null, ...options });
|
||||||
|
}
|
||||||
|
return originalClick(element, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also update asyncWrapper to call tick() after async operations so Svelte
|
||||||
|
// DOM updates are flushed. @testing-library/svelte's act() already does this,
|
||||||
|
// but we re-configure after it to preserve our fake-timer behaviour.
|
||||||
|
beforeEach(() => {
|
||||||
|
configure({
|
||||||
|
asyncWrapper: async (fn: () => Promise<unknown>) => {
|
||||||
|
const result = await fn();
|
||||||
|
await tick();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user