Compare commits
119 Commits
feat/issue
...
0596fddcd3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0596fddcd3 | |||
| 008c725813 | |||
| 1739b70d54 | |||
| 3b829325f2 | |||
| d139e5e28c | |||
| c9d6564fbe | |||
| ba79cff4e7 | |||
| 55285e7d5d | |||
| 055ae11fa3 | |||
| bf18f2bd84 | |||
| da21a12222 | |||
| e9dc04b2a5 | |||
| 8dfc3df06b | |||
| ea070b4760 | |||
| aecdf249d6 | |||
| e4345350ad | |||
| 56decf155d | |||
| 1de4b15e34 | |||
| ccec0baa99 | |||
| 9928591b48 | |||
| 89a549a1c8 | |||
| c24281dd4c | |||
| 8051fcbe22 | |||
| b45ab0fd46 | |||
| 2bbc3762e2 | |||
| a751b0758a | |||
| 8234c2f162 | |||
| 257808016d | |||
| cd7f4a1ea0 | |||
| b673a466e9 | |||
| e3066ec3e5 | |||
| bd1604fc1d | |||
| c297403506 | |||
| fa4a4c9ef7 | |||
| 6dd0b7ac93 | |||
| f0bbb3b009 | |||
| b4fa3ca23e | |||
| 9482ecbf36 | |||
| 278fda7d90 | |||
| 8e3256d960 | |||
| 30722d9bcc | |||
| dd9a86d4e9 | |||
| c8c2605f31 | |||
| 1b2a02881d | |||
| 8756bf93d9 | |||
| dac83c70ea | |||
| 5b8d336d21 | |||
| e5d96cd85a | |||
| ea7113ec53 | |||
| 4333dc0d84 | |||
| cbafe783e9 | |||
| 178c888635 | |||
| f5adc051e8 | |||
| 90c9ea1894 | |||
| ba41f6984b | |||
| 25c575c167 | |||
| 36ae82af5d | |||
| 7175b56833 | |||
| a52b0a9d24 | |||
| f6265efa92 | |||
| 3cd9154550 | |||
| be43fe94b6 | |||
| e3afe1b4f2 | |||
| eb5ee1ab5a | |||
| 9d210befa1 | |||
| 40a6a0e92d | |||
| 40ee4dad53 | |||
| 741141168b | |||
| 6cc79836d5 | |||
| 5ac8f1768f | |||
| 7bdadbe962 | |||
| 2151dff4db | |||
| e831480860 | |||
| 92922533ac | |||
| 16b70bd818 | |||
| 5325f4827e | |||
| c26c2e1973 | |||
| 93e8bf9e41 | |||
| 7e254fc280 | |||
| 3be9f502c6 | |||
| 2f690eb3cb | |||
| 2253c76287 | |||
| e12fb72fc2 | |||
| 693ec2b997 | |||
| e73a84af5f | |||
| 27e09a77d6 | |||
| 6d76da5542 | |||
| 8e82213d1e | |||
| cb15143c30 | |||
| 9adf786b8f | |||
| 1bf929280b | |||
| 75c860a62b | |||
| 8ad636f825 | |||
| 7c07bc443b | |||
| 05e47c3dac | |||
| 5d2bb9e84e | |||
| e3f8d8ad73 | |||
| 0511a735a5 | |||
| 33f3b30cb4 | |||
| e4d3008139 | |||
| 6505cb4251 | |||
| 3d49e6b7bf | |||
| 4e2b0b5727 | |||
| 2cef8a1169 | |||
| fcf0f297bb | |||
| 0256b4360b | |||
| 00c48a7c96 | |||
| ce860d68e4 | |||
| b39d04acce | |||
| c7e56a173d | |||
| 86a25eb038 | |||
| a34c6f30f2 | |||
| 9bb6293d9f | |||
| 47c748145d | |||
| a25286e385 | |||
| a733e8dd66 | |||
| 35ed6ca878 | |||
| dc99459a2e | |||
| 021d308a71 |
@@ -0,0 +1,7 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
public class ForbiddenException extends RuntimeException {
|
||||
public ForbiddenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ public class GlobalExceptionHandler {
|
||||
.body(ApiError.of("CONFLICT", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ForbiddenException.class)
|
||||
public ResponseEntity<ApiError> handleForbidden(ForbiddenException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiError.of("FORBIDDEN", ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ValidationException.class)
|
||||
public ResponseEntity<ApiError> handleBusinessValidation(ValidationException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
@Component
|
||||
public class HouseholdRoleInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public HouseholdRoleInterceptor(HouseholdResolver householdResolver) {
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
RequiresHouseholdRole annotation = handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class);
|
||||
if (annotation == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null) {
|
||||
throw new ForbiddenException("Not authenticated");
|
||||
}
|
||||
|
||||
String actualRole = householdResolver.resolveRole(auth.getName());
|
||||
if (!annotation.value().equals(actualRole)) {
|
||||
throw new ForbiddenException("Insufficient permissions");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RequiresHouseholdRole {
|
||||
String value();
|
||||
}
|
||||
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal file
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final HouseholdRoleInterceptor householdRoleInterceptor;
|
||||
|
||||
public WebMvcConfig(HouseholdRoleInterceptor householdRoleInterceptor) {
|
||||
this.householdRoleInterceptor = householdRoleInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(householdRoleInterceptor);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class PlanningService {
|
||||
|
||||
private static final double MAX_VARIETY_SCORE = 10.0;
|
||||
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final WeekPlanSlotRepository weekPlanSlotRepository;
|
||||
private final CookingLogRepository cookingLogRepository;
|
||||
@@ -135,6 +137,8 @@ public class PlanningService {
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
|
||||
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
Set<String> lowerTagFilters = tagFilters.stream()
|
||||
@@ -145,11 +149,13 @@ public class PlanningService {
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.filter(r -> matchesAllTags(r, lowerTagFilters))
|
||||
.map(candidate -> {
|
||||
double score = simulateVarietyScore(
|
||||
double simulatedScore = simulateVarietyScore(
|
||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
|
||||
double scoreDelta = simulatedScore - currentScore;
|
||||
boolean hasConflict = scoreDelta < 0;
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
|
||||
})
|
||||
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
|
||||
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
|
||||
@@ -166,36 +172,65 @@ public class PlanningService {
|
||||
|
||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
||||
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
if (!slot.getSlotDate().equals(slotDate)) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
}
|
||||
}
|
||||
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
||||
return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds);
|
||||
}
|
||||
|
||||
private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
List<SimulatedSlot> currentSlots = plan.getSlots().stream()
|
||||
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
|
||||
.toList();
|
||||
return currentSlots.isEmpty() ? MAX_VARIETY_SCORE
|
||||
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
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());
|
||||
|
||||
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
|
||||
double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds);
|
||||
|
||||
return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore);
|
||||
}
|
||||
|
||||
private double scoreFromSimulatedSlots(List<SimulatedSlot> slots, VarietyScoreConfig config,
|
||||
Set<UUID> recentlyCookedIds) {
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (SimulatedSlot slot : slots) {
|
||||
for (Tag tag : slot.recipe.getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long tagRepeatCount = tagDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (SimulatedSlot slot : slots) {
|
||||
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
@@ -203,34 +238,35 @@ public class PlanningService {
|
||||
}
|
||||
}
|
||||
}
|
||||
long ingredientOverlapCount = ingredientDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
long recentRepeatCount = simulatedSlots.stream()
|
||||
long recentRepeatCount = slots.stream()
|
||||
.map(s -> s.recipe.getId())
|
||||
.distinct()
|
||||
.filter(recentlyCookedIds::contains)
|
||||
.count();
|
||||
|
||||
// 4. Duplicate recipes within the simulated plan
|
||||
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
|
||||
// 4. Duplicate recipes within the plan
|
||||
Map<UUID, Long> recipeCounts = slots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
|
||||
long duplicatePenaltyCount = recipeCounts.values().stream()
|
||||
.filter(c -> c > 1)
|
||||
.mapToLong(c -> c - 1)
|
||||
.sum();
|
||||
|
||||
double score = 10.0;
|
||||
score -= tagRepeatCount * wTagRepeat;
|
||||
score -= ingredientOverlapCount * wIngredientOverlap;
|
||||
score -= recentRepeatCount * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
return Math.max(0, Math.min(10, score));
|
||||
return applyPenalties(tagRepeatCount, ingredientOverlapCount, recentRepeatCount, duplicatePenaltyCount, config);
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
private double applyPenalties(long tagRepeats, long ingredientOverlaps, long recentRepeats,
|
||||
long duplicates, VarietyScoreConfig config) {
|
||||
double score = MAX_VARIETY_SCORE;
|
||||
score -= tagRepeats * config.getWTagRepeat().doubleValue();
|
||||
score -= ingredientOverlaps * config.getWIngredientOverlap().doubleValue();
|
||||
score -= recentRepeats * config.getWRecentRepeat().doubleValue();
|
||||
score -= duplicates * config.getWPlanDuplicate().doubleValue();
|
||||
return Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
|
||||
@@ -246,10 +282,6 @@ public class PlanningService {
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
int historyDays = config.getHistoryDays();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
@@ -317,13 +349,7 @@ public class PlanningService {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
double score = 10.0;
|
||||
score -= tagRepeats.size() * wTagRepeat;
|
||||
score -= overlaps.size() * wIngredientOverlap;
|
||||
score -= recentRepeats.size() * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
score = Math.max(0, Math.min(10, score));
|
||||
double score = applyPenalties(tagRepeats.size(), overlaps.size(), recentRepeats.size(), duplicatePenaltyCount, config);
|
||||
|
||||
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.recipeapp.planning;
|
||||
|
||||
import com.recipeapp.common.RequiresHouseholdRole;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -40,6 +41,7 @@ public class WeekPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/slots")
|
||||
@RequiresHouseholdRole("planner")
|
||||
public ResponseEntity<SlotResponse> addSlot(
|
||||
Principal principal,
|
||||
@PathVariable UUID id,
|
||||
@@ -50,6 +52,7 @@ public class WeekPlanController {
|
||||
}
|
||||
|
||||
@PatchMapping("/{planId}/slots/{slotId}")
|
||||
@RequiresHouseholdRole("planner")
|
||||
public SlotResponse updateSlot(
|
||||
Principal principal,
|
||||
@PathVariable UUID planId,
|
||||
@@ -61,6 +64,7 @@ public class WeekPlanController {
|
||||
|
||||
@DeleteMapping("/{planId}/slots/{slotId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequiresHouseholdRole("planner")
|
||||
public void deleteSlot(
|
||||
Principal principal,
|
||||
@PathVariable UUID planId,
|
||||
@@ -92,4 +96,15 @@ public class WeekPlanController {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return planningService.getVarietyScore(householdId, id);
|
||||
}
|
||||
|
||||
@GetMapping("/{planId}/variety-preview")
|
||||
@RequiresHouseholdRole("member")
|
||||
public VarietyPreviewResponse getVarietyPreview(
|
||||
Principal principal,
|
||||
@PathVariable UUID planId,
|
||||
@RequestParam UUID recipeId,
|
||||
@RequestParam LocalDate date) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return planningService.getVarietyPreview(householdId, planId, recipeId, date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
||||
|
||||
public record SuggestionItem(
|
||||
SlotResponse.SlotRecipe recipe,
|
||||
double simulatedScore
|
||||
double scoreDelta,
|
||||
boolean hasConflict
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.recipeapp.planning.dto;
|
||||
|
||||
public record VarietyPreviewResponse(
|
||||
double currentScore,
|
||||
double projectedScore,
|
||||
double scoreDelta
|
||||
) {}
|
||||
@@ -24,6 +24,10 @@ public class HouseholdResolver {
|
||||
return findMembership(userEmail).getUser().getId();
|
||||
}
|
||||
|
||||
public String resolveRole(String userEmail) {
|
||||
return findMembership(userEmail).getRole();
|
||||
}
|
||||
|
||||
private HouseholdMember findMembership(String userEmail) {
|
||||
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
||||
|
||||
@@ -22,8 +22,8 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
FROM Recipe r
|
||||
WHERE r.household.id = :householdId
|
||||
AND r.deletedAt IS NULL
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
|
||||
AND (:effort IS NULL OR r.effort = :effort)
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
||||
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
ORDER BY r.createdAt DESC
|
||||
@@ -43,8 +43,8 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
FROM Recipe r
|
||||
WHERE r.household.id = :householdId
|
||||
AND r.deletedAt IS NULL
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', :search, '%')))
|
||||
AND (:effort IS NULL OR r.effort = :effort)
|
||||
AND (:search IS NULL OR LOWER(r.name) LIKE LOWER(CONCAT('%', CAST(:search AS string), '%')))
|
||||
AND (:effort IS NULL OR r.effort = CAST(:effort AS string))
|
||||
AND (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
AND (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
""")
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.common.RequiresHouseholdRole;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -19,8 +23,21 @@ public class ShoppingListController {
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping("/v1/shopping-list")
|
||||
public ShoppingListResponse getByWeekStart(
|
||||
@RequestParam(required = false) LocalDate weekStart,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
ShoppingListResponse response = shoppingService.getByWeekStart(householdId, weekStart);
|
||||
if (response == null) {
|
||||
throw new ResourceNotFoundException("No shopping list for this week");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@PostMapping("/v1/week-plans/{id}/shopping-list")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequiresHouseholdRole("planner")
|
||||
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.generateFromPlan(householdId, id);
|
||||
@@ -45,7 +62,7 @@ public class ShoppingListController {
|
||||
@PostMapping("/v1/shopping-lists/{id}/items")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ShoppingListItemResponse addItem(@PathVariable UUID id,
|
||||
@RequestBody AddItemRequest request,
|
||||
@Valid @RequestBody AddItemRequest request,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.addItem(householdId, id, request);
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.recipeapp.shopping;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ShoppingListRepository extends JpaRepository<ShoppingList, UUID> {
|
||||
Optional<ShoppingList> findByHouseholdIdAndWeekPlanWeekStart(UUID householdId, LocalDate weekStart);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import com.recipeapp.shopping.entity.ShoppingList;
|
||||
@@ -16,6 +18,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -29,19 +34,34 @@ public class ShoppingService {
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
|
||||
public ShoppingService(ShoppingListRepository shoppingListRepository,
|
||||
ShoppingListItemRepository shoppingListItemRepository,
|
||||
WeekPlanRepository weekPlanRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
UserAccountRepository userAccountRepository,
|
||||
RecipeRepository recipeRepository) {
|
||||
this.shoppingListRepository = shoppingListRepository;
|
||||
this.shoppingListItemRepository = shoppingListItemRepository;
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.recipeRepository = recipeRepository;
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ShoppingListResponse getByWeekStart(UUID householdId, LocalDate weekStart) {
|
||||
if (weekStart == null) {
|
||||
weekStart = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||
}
|
||||
|
||||
return shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekStart)
|
||||
.map(this::toResponse)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,45 +73,71 @@ public class ShoppingService {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
|
||||
var household = weekPlan.getHousehold();
|
||||
|
||||
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
|
||||
shoppingList = shoppingListRepository.save(shoppingList);
|
||||
// Find or create the shopping list
|
||||
ShoppingList shoppingList = shoppingListRepository
|
||||
.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart())
|
||||
.orElseGet(() -> {
|
||||
var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan);
|
||||
return shoppingListRepository.save(newList);
|
||||
});
|
||||
|
||||
// Aggregate ingredients across all slots/recipes
|
||||
// Key: ingredientId + unit -> merged data
|
||||
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
|
||||
|
||||
for (var slot : weekPlan.getSlots()) {
|
||||
var recipe = slot.getRecipe();
|
||||
for (RecipeIngredient ri : recipe.getIngredients()) {
|
||||
Ingredient ingredient = ri.getIngredient();
|
||||
|
||||
// Filter out staples
|
||||
if (ingredient.isStaple()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = ingredient.getId().toString() + "|" + ri.getUnit();
|
||||
String key = mergeKey(ingredient.getId(), ri.getUnit());
|
||||
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
|
||||
.addQuantity(ri.getQuantity())
|
||||
.addRecipeId(recipe.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// Create shopping list items
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList,
|
||||
mi.ingredient,
|
||||
null,
|
||||
mi.totalQuantity,
|
||||
mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
|
||||
);
|
||||
shoppingList.getItems().add(item);
|
||||
// Build index of existing generated items by merge key
|
||||
Map<String, ShoppingListItem> existingByKey = new HashMap<>();
|
||||
List<ShoppingListItem> customItems = new ArrayList<>();
|
||||
for (ShoppingListItem item : shoppingList.getItems()) {
|
||||
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
|
||||
// Generated item
|
||||
String key = mergeKey(item.getIngredient() != null ? item.getIngredient().getId() : null, item.getUnit());
|
||||
existingByKey.put(key, item);
|
||||
} else {
|
||||
customItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: update existing, add new, collect keys to keep
|
||||
Set<String> mergedKeys = new HashSet<>();
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
String key = mergeKey(mi.ingredient.getId(), mi.unit);
|
||||
mergedKeys.add(key);
|
||||
|
||||
ShoppingListItem existing = existingByKey.get(key);
|
||||
if (existing != null) {
|
||||
// Update quantity and sources, preserve check state
|
||||
existing.setQuantity(mi.totalQuantity);
|
||||
existing.setSourceRecipes(mi.recipeIds.stream().distinct().toArray(UUID[]::new));
|
||||
} else {
|
||||
// New item
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList, mi.ingredient, null, mi.totalQuantity, mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new));
|
||||
shoppingList.getItems().add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove generated items no longer in the plan
|
||||
shoppingList.getItems().removeIf(item ->
|
||||
item.getSourceRecipes() != null && item.getSourceRecipes().length > 0
|
||||
&& !mergedKeys.contains(mergeKey(
|
||||
item.getIngredient() != null ? item.getIngredient().getId() : null,
|
||||
item.getUnit())));
|
||||
|
||||
shoppingList.setGeneratedAt(java.time.Instant.now());
|
||||
shoppingListRepository.save(shoppingList);
|
||||
|
||||
return toResponse(shoppingList);
|
||||
@@ -121,7 +167,7 @@ public class ShoppingService {
|
||||
}
|
||||
|
||||
shoppingListItemRepository.save(item);
|
||||
return toItemResponse(item);
|
||||
return toItemResponseWithNames(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +192,7 @@ public class ShoppingService {
|
||||
item = shoppingListItemRepository.save(item);
|
||||
list.getItems().add(item);
|
||||
|
||||
return toItemResponse(item);
|
||||
return toItemResponseWithNames(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -178,18 +224,53 @@ public class ShoppingService {
|
||||
}
|
||||
|
||||
private ShoppingListResponse toResponse(ShoppingList list) {
|
||||
// Batch-fetch recipe names for source references
|
||||
Set<UUID> allRecipeIds = list.getItems().stream()
|
||||
.filter(i -> i.getSourceRecipes() != null)
|
||||
.flatMap(i -> Arrays.stream(i.getSourceRecipes()))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<UUID, String> recipeNames = allRecipeIds.isEmpty()
|
||||
? Map.of()
|
||||
: recipeRepository.findAllById(allRecipeIds).stream()
|
||||
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
|
||||
|
||||
List<ShoppingListItemResponse> items = list.getItems().stream()
|
||||
.map(this::toItemResponse)
|
||||
.map(item -> toItemResponse(item, recipeNames))
|
||||
.toList();
|
||||
|
||||
// Count filtered staples from the week plan
|
||||
int filteredStaplesCount = countFilteredStaples(list.getWeekPlan());
|
||||
|
||||
return new ShoppingListResponse(
|
||||
list.getId(),
|
||||
list.getWeekPlan().getId(),
|
||||
list.getGeneratedAt(),
|
||||
filteredStaplesCount,
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponse(ShoppingListItem item) {
|
||||
private int countFilteredStaples(WeekPlan weekPlan) {
|
||||
return (int) weekPlan.getSlots().stream()
|
||||
.flatMap(slot -> slot.getRecipe().getIngredients().stream())
|
||||
.map(RecipeIngredient::getIngredient)
|
||||
.filter(Ingredient::isStaple)
|
||||
.map(Ingredient::getId)
|
||||
.distinct()
|
||||
.count();
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponseWithNames(ShoppingListItem item) {
|
||||
Map<UUID, String> recipeNames = Map.of();
|
||||
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
|
||||
recipeNames = recipeRepository.findAllById(Arrays.asList(item.getSourceRecipes())).stream()
|
||||
.collect(Collectors.toMap(Recipe::getId, Recipe::getName));
|
||||
}
|
||||
return toItemResponse(item, recipeNames);
|
||||
}
|
||||
|
||||
private ShoppingListItemResponse toItemResponse(ShoppingListItem item, Map<UUID, String> recipeNames) {
|
||||
String name;
|
||||
ShoppingListItemResponse.CategoryRef categoryRef = null;
|
||||
UUID ingredientId = null;
|
||||
@@ -207,6 +288,14 @@ public class ShoppingService {
|
||||
name = item.getCustomName();
|
||||
}
|
||||
|
||||
List<ShoppingListItemResponse.RecipeRef> sourceRefs = item.getSourceRecipes() != null
|
||||
? Arrays.stream(item.getSourceRecipes())
|
||||
.distinct()
|
||||
.filter(recipeNames::containsKey)
|
||||
.map(id -> new ShoppingListItemResponse.RecipeRef(id, recipeNames.get(id)))
|
||||
.toList()
|
||||
: List.of();
|
||||
|
||||
return new ShoppingListItemResponse(
|
||||
item.getId(),
|
||||
ingredientId,
|
||||
@@ -216,10 +305,14 @@ public class ShoppingService {
|
||||
item.getUnit(),
|
||||
item.isChecked(),
|
||||
item.getCheckedBy() != null ? item.getCheckedBy().getId() : null,
|
||||
item.getSourceRecipes() != null ? Arrays.asList(item.getSourceRecipes()) : List.of()
|
||||
sourceRefs
|
||||
);
|
||||
}
|
||||
|
||||
private static String mergeKey(UUID ingredientId, String unit) {
|
||||
return (ingredientId != null ? ingredientId.toString() : "") + "|" + unit;
|
||||
}
|
||||
|
||||
private static class MergedIngredient {
|
||||
final Ingredient ingredient;
|
||||
final String unit;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AddItemRequest(
|
||||
UUID ingredientId,
|
||||
String customName,
|
||||
BigDecimal quantity,
|
||||
@NotBlank @Size(max = 255) String customName,
|
||||
@Positive BigDecimal quantity,
|
||||
String unit
|
||||
) {}
|
||||
|
||||
@@ -13,7 +13,8 @@ public record ShoppingListItemResponse(
|
||||
String unit,
|
||||
boolean isChecked,
|
||||
UUID checkedBy,
|
||||
List<UUID> sourceRecipes
|
||||
List<RecipeRef> sourceRecipes
|
||||
) {
|
||||
public record CategoryRef(UUID id, String name) {}
|
||||
public record RecipeRef(UUID id, String name) {}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.recipeapp.shopping.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ShoppingListResponse(
|
||||
UUID id,
|
||||
UUID weekPlanId,
|
||||
Instant generatedAt,
|
||||
int filteredStaplesCount,
|
||||
List<ShoppingListItemResponse> items
|
||||
) {}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.recipeapp.shopping.entity;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -23,6 +24,9 @@ public class ShoppingList {
|
||||
@JoinColumn(name = "week_plan_id", nullable = false)
|
||||
private WeekPlan weekPlan;
|
||||
|
||||
@Column(name = "generated_at", nullable = false)
|
||||
private Instant generatedAt = Instant.now();
|
||||
|
||||
@OneToMany(mappedBy = "shoppingList", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<ShoppingListItem> items = new ArrayList<>();
|
||||
|
||||
@@ -36,5 +40,7 @@ public class ShoppingList {
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public WeekPlan getWeekPlan() { return weekPlan; }
|
||||
public Instant getGeneratedAt() { return generatedAt; }
|
||||
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
|
||||
public List<ShoppingListItem> getItems() { return items; }
|
||||
}
|
||||
|
||||
4
backend/src/main/resources/application-docker.yml
Normal file
4
backend/src/main/resources/application-docker.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
flyway:
|
||||
locations: classpath:db/migration,classpath:db/seed
|
||||
out-of-order: true
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE shopping_list
|
||||
ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now();
|
||||
182
backend/src/main/resources/db/seed/V100__dev_seed.sql
Normal file
182
backend/src/main/resources/db/seed/V100__dev_seed.sql
Normal file
@@ -0,0 +1,182 @@
|
||||
-- Dev seed: German household with Italian-leaning staples
|
||||
-- Fixed UUIDs so the migration is idempotent and references are stable.
|
||||
|
||||
-- ─── User & Household ────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO user_account (id, email, password_hash, display_name, created_at)
|
||||
VALUES (
|
||||
'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
'dev@mealprep.local',
|
||||
-- bcrypt of "dev" — never expose this outside local dev
|
||||
'$2a$10$IK233Yyc62EHt2hL5fw9F.0fBlEdoERr75LldZD35VFAAYfnkaOuK',
|
||||
'Dev User',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO household (id, name, created_by, created_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Musterhaushalt',
|
||||
'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO household_member (household_id, user_id, role, joined_at)
|
||||
VALUES (
|
||||
'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
'planner',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- ─── Ingredient Categories ───────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO ingredient_category (id, household_id, name, sort_order) VALUES
|
||||
('cc000001-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gemüse', 1),
|
||||
('cc000001-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Obst', 2),
|
||||
('cc000001-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Fleisch & Fisch', 3),
|
||||
('cc000001-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Milchprodukte & Eier', 4),
|
||||
('cc000001-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getreide & Nudeln', 5),
|
||||
('cc000001-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hülsenfrüchte', 6),
|
||||
('cc000001-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Konserven', 7),
|
||||
('cc000001-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürze & Kräuter', 8),
|
||||
('cc000001-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Öle & Essig', 9),
|
||||
('cc000001-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Saucen & Pasten', 10),
|
||||
('cc000001-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Nüsse & Samen', 11),
|
||||
('cc000001-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Backzutaten', 12),
|
||||
('cc000001-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tiefkühl', 13),
|
||||
('cc000001-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getränke', 14)
|
||||
ON CONFLICT (household_id, name) DO NOTHING;
|
||||
|
||||
-- ─── Staple Ingredients ──────────────────────────────────────────────────────
|
||||
-- is_staple = true means "always keep in stock"
|
||||
|
||||
-- Gemüse (frisch → kein Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zwiebeln', true, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Knoblauch', true, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Karotten', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Staudensellerie', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomaten', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paprika', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zucchini', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Aubergine', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Spinat', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Brokkoli', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kartoffeln', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Süßkartoffeln', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lauch', false, 'cc000001-0000-0000-0000-000000000001')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Obst (frisch → kein Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zitronen', false, 'cc000001-0000-0000-0000-000000000002'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Limetten', false, 'cc000001-0000-0000-0000-000000000002')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Fleisch & Fisch (frisches Fleisch → kein Staple; Konserven/Gepökeltes → Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hähnchenbrust', false, 'cc000001-0000-0000-0000-000000000003'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hackfleisch (gemischt)', false, 'cc000001-0000-0000-0000-000000000003'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Pancetta', true, 'cc000001-0000-0000-0000-000000000003'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Thunfisch (Dose)', true, 'cc000001-0000-0000-0000-000000000003')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Milchprodukte & Eier (frisch → kein Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Eier', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Butter', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Parmesan', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Mozzarella', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sahne', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Schmand', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Ricotta', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Milch', false, 'cc000001-0000-0000-0000-000000000004')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Getreide & Nudeln (Pasta → kein Staple; Trockenvorräte wie Reis/Mehl → Staple)
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Spaghetti', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Penne', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tagliatelle', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lasagneplatten', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Risottoreis (Arborio)', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basmati-Reis', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Weizenmehl (Type 405)', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paniermehl', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Polenta', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Haferflocken', true, 'cc000001-0000-0000-0000-000000000005')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Hülsenfrüchte
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kichererbsen (Dose)', true, 'cc000001-0000-0000-0000-000000000006'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Linsen', true, 'cc000001-0000-0000-0000-000000000006'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Cannellini-Bohnen (Dose)', true, 'cc000001-0000-0000-0000-000000000006')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Konserven
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Gehackte Tomaten (Dose)', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'San-Marzano-Tomaten (Dose)', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomatenmark', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Gemüsebrühe', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Hühnerbrühe', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kapern', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Oliven (schwarz)', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sardellen (Dose)', true, 'cc000001-0000-0000-0000-000000000007')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Gewürze & Kräuter
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Salz', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Schwarzer Pfeffer', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Oregano (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Thymian (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rosmarin (getrocknet)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Lorbeerblätter', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Paprikapulver (edelsüß)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Chiliflocken', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Muskat (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zimt (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Kümmel (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Zucker', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Knoblauchpulver', true, 'cc000001-0000-0000-0000-000000000008')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Öle & Essig
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Olivenöl (extra vergine)', true, 'cc000001-0000-0000-0000-000000000009'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rapsöl', true, 'cc000001-0000-0000-0000-000000000009'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Balsamico-Essig', true, 'cc000001-0000-0000-0000-000000000009'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Weißweinessig', true, 'cc000001-0000-0000-0000-000000000009')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Saucen & Pasten
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum-Pesto', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomatenpassata', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sojasauce', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Worcestershiresauce', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Senf (mittelscharf)', true, 'cc000001-0000-0000-0000-000000000010')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Nüsse & Samen
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Pinienkerne', true, 'cc000001-0000-0000-0000-000000000011'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Walnüsse', true, 'cc000001-0000-0000-0000-000000000011'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Sonnenblumenkerne', true, 'cc000001-0000-0000-0000-000000000011'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Rosinen', true, 'cc000001-0000-0000-0000-000000000011')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Backzutaten
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Backpulver', true, 'cc000001-0000-0000-0000-000000000012'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Trockenhefe', true, 'cc000001-0000-0000-0000-000000000012'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Natron', true, 'cc000001-0000-0000-0000-000000000012'),
|
||||
(gen_random_uuid(), 'bbbbbbbb-0000-0000-0000-000000000001', 'Vanilleextrakt', true, 'cc000001-0000-0000-0000-000000000012')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.recipeapp.common;
|
||||
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HouseholdRoleInterceptorTest {
|
||||
|
||||
@Mock private HouseholdResolver householdResolver;
|
||||
@Mock private HttpServletRequest request;
|
||||
@Mock private HttpServletResponse response;
|
||||
|
||||
@InjectMocks private HouseholdRoleInterceptor interceptor;
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(String email) {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(email, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowWhenUserHasRequiredRole() throws Exception {
|
||||
authenticateAs("planner@example.com");
|
||||
when(householdResolver.resolveRole("planner@example.com")).thenReturn("planner");
|
||||
|
||||
var handlerMethod = mock(HandlerMethod.class);
|
||||
var annotation = mock(RequiresHouseholdRole.class);
|
||||
when(annotation.value()).thenReturn("planner");
|
||||
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, handlerMethod);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowForbiddenWhenUserLacksRequiredRole() {
|
||||
authenticateAs("member@example.com");
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
var handlerMethod = mock(HandlerMethod.class);
|
||||
var annotation = mock(RequiresHouseholdRole.class);
|
||||
when(annotation.value()).thenReturn("planner");
|
||||
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
|
||||
|
||||
assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod))
|
||||
.isInstanceOf(ForbiddenException.class)
|
||||
.hasMessage("Insufficient permissions");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassThroughWhenNoAnnotation() throws Exception {
|
||||
var handlerMethod = mock(HandlerMethod.class);
|
||||
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(null);
|
||||
|
||||
boolean result = interceptor.preHandle(request, response, handlerMethod);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPassThroughWhenNotHandlerMethod() throws Exception {
|
||||
boolean result = interceptor.preHandle(request, response, new Object());
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -443,4 +443,93 @@ class PlanningServiceTest {
|
||||
assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── Variety preview ──
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldReturnScoreDeltaForDifferentRecipe() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var planId = plan.getId();
|
||||
|
||||
// Plan already has one slot (Mon) with Spaghetti
|
||||
var existingRecipe = testRecipe(household, "Spaghetti");
|
||||
var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
// Candidate is Lachsfilet (different recipe, no shared tags/ingredients)
|
||||
var candidate = testRecipe(household, "Lachsfilet");
|
||||
var candidateId = candidate.getId();
|
||||
|
||||
when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(candidateId, HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.of(candidate));
|
||||
when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty());
|
||||
when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = planningService.getVarietyPreview(HOUSEHOLD_ID, planId, candidateId, WEEK_START.plusDays(1));
|
||||
|
||||
// 1 existing slot with no conflicts → currentScore = 10.0
|
||||
// Adding a different recipe with no tags/ingredients → projectedScore = 10.0, delta = 0
|
||||
assertThat(result.currentScore()).isEqualTo(10.0);
|
||||
assertThat(result.projectedScore()).isEqualTo(10.0);
|
||||
assertThat(result.scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldReturnNegativeDeltaForDuplicateRecipe() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var planId = plan.getId();
|
||||
|
||||
// Plan already has Spaghetti on Mon
|
||||
var existingRecipe = testRecipe(household, "Spaghetti");
|
||||
var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
// Candidate is the same Spaghetti recipe → triggers duplicate penalty (wPlanDuplicate = 2.0)
|
||||
when(weekPlanRepository.findById(planId)).thenReturn(Optional.of(plan));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(existingRecipe.getId(), HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.of(existingRecipe));
|
||||
when(varietyScoreConfigRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(Optional.empty());
|
||||
when(cookingLogRepository.findByHouseholdIdAndCookedOnAfter(eq(HOUSEHOLD_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = planningService.getVarietyPreview(
|
||||
HOUSEHOLD_ID, planId, existingRecipe.getId(), WEEK_START.plusDays(1));
|
||||
|
||||
// currentScore = 10.0 (1 slot, no conflicts)
|
||||
// projectedScore = 10.0 - 1 * 2.0 (duplicate penalty) = 8.0
|
||||
assertThat(result.currentScore()).isEqualTo(10.0);
|
||||
assertThat(result.projectedScore()).isEqualTo(8.0);
|
||||
assertThat(result.scoreDelta()).isEqualTo(-2.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldThrowWhenPlanNotFound() {
|
||||
var planId = UUID.randomUUID();
|
||||
when(weekPlanRepository.findById(planId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> planningService.getVarietyPreview(
|
||||
HOUSEHOLD_ID, planId, UUID.randomUUID(), WEEK_START))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldThrowWhenRecipeNotFound() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var recipeId = UUID.randomUUID();
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, HOUSEHOLD_ID))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> planningService.getVarietyPreview(
|
||||
HOUSEHOLD_ID, plan.getId(), recipeId, WEEK_START))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class SuggestionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() {
|
||||
void emptyPlanWithRecipesShouldReturnAllWithZeroDelta() {
|
||||
var plan = createPlan();
|
||||
var r1 = createRecipe("Pasta");
|
||||
var r2 = createRecipe("Salad");
|
||||
@@ -179,8 +179,12 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(3);
|
||||
assertThat(result.suggestions()).allSatisfy(s ->
|
||||
assertThat(s.simulatedScore()).isEqualTo(10.0));
|
||||
// Empty plan → currentScore = 10.0; no penalties → scoreDelta = 0.0 for all
|
||||
// hasConflict = (scoreDelta < 0) = false for neutral recipes
|
||||
assertThat(result.suggestions()).allSatisfy(s -> {
|
||||
assertThat(s.scoreDelta()).isEqualTo(0.0);
|
||||
assertThat(s.hasConflict()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -204,6 +208,28 @@ class SuggestionsTest {
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void topNZeroShouldReturnEmptyList() {
|
||||
var plan = createPlan();
|
||||
stubPlan(plan);
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 0);
|
||||
|
||||
assertThat(result.suggestions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void topNNegativeShouldReturnEmptyList() {
|
||||
var plan = createPlan();
|
||||
stubPlan(plan);
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), -1);
|
||||
|
||||
assertThat(result.suggestions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleCandidateShouldReturnOne() {
|
||||
var plan = createPlan();
|
||||
@@ -221,6 +247,148 @@ class SuggestionsTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Category 1b: scoreDelta and hasConflict
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@Nested
|
||||
class ScoreDeltaAndHasConflict {
|
||||
|
||||
@Test
|
||||
void recipeWithZeroDeltaOnEmptyPlanShouldNotHaveConflict() {
|
||||
// Empty plan → currentScore = 10.0. Clean recipe → simulatedScore = 10.0.
|
||||
// scoreDelta = 0.0. No worsening → hasConflict = false.
|
||||
var plan = createPlan();
|
||||
var recipe = createRecipe("Clean Recipe");
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(recipe);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.scoreDelta()).isEqualTo(0.0);
|
||||
assertThat(item.hasConflict()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeWithTagConflictShouldHaveNegativeDeltaAndHasConflict() {
|
||||
// Existing slot Mon=Monday Pasta (cuisine tag). Adding Tue=More Pasta → tag repeat penalty (-1.5).
|
||||
// currentScore = 10.0 (1 slot, no consecutive). simulatedScore = 10.0 - 1.5 = 8.5.
|
||||
// scoreDelta = -1.5, hasConflict = true.
|
||||
var plan = createPlan();
|
||||
var pastaTag = createTag("Pasta", "cuisine");
|
||||
var existingRecipe = createRecipe("Monday Pasta");
|
||||
addTag(existingRecipe, pastaTag);
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
|
||||
var candidate = createRecipe("More Pasta");
|
||||
addTag(candidate, pastaTag);
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(existingRecipe, candidate);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.scoreDelta()).isEqualTo(-1.5);
|
||||
assertThat(item.hasConflict()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeWithIngredientConflictShouldHaveNegativeDeltaAndHasConflict() {
|
||||
// Existing slot Mon=Tomato Soup (tomato ingredient). Adding Tue=Tomato Pasta → overlap (-0.3).
|
||||
// currentScore = 10.0, simulatedScore = 9.7, scoreDelta = -0.3, hasConflict = true.
|
||||
var plan = createPlan();
|
||||
var tomato = createIngredient("Tomatoes", false);
|
||||
var existingRecipe = createRecipe("Tomato Soup");
|
||||
addIngredient(existingRecipe, tomato);
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
|
||||
var candidate = createRecipe("Tomato Pasta");
|
||||
addIngredient(candidate, tomato);
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(existingRecipe, candidate);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.scoreDelta()).isCloseTo(-0.3, within(0.001));
|
||||
assertThat(item.hasConflict()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void swappingExistingSlotForCleanRecipeShouldHavePositiveDelta() {
|
||||
// Plan has Mon=ItalianA, Tue=ItalianB → consecutive cuisine tag repeat → currentScore = 8.5
|
||||
// Asking for suggestions for Mon (swap scenario).
|
||||
// CleanRecipe (no Italian tag) → correct simulation: [Mon:CleanRecipe, Tue:ItalianB] → no repeat → 10.0
|
||||
// scoreDelta = +1.5 → hasConflict = false
|
||||
var plan = createPlan();
|
||||
var italianTag = createTag("Italienisch", "cuisine");
|
||||
var italianA = createRecipe("Spaghetti Carbonara");
|
||||
addTag(italianA, italianTag);
|
||||
addSlot(plan, italianA, MONDAY);
|
||||
var italianB = createRecipe("Penne Arrabiata");
|
||||
addTag(italianB, italianTag);
|
||||
addSlot(plan, italianB, MONDAY.plusDays(1));
|
||||
var cleanRecipe = createRecipe("Grillhähnchen");
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(italianA, italianB, cleanRecipe);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
var item = result.suggestions().getFirst();
|
||||
assertThat(item.recipe().name()).isEqualTo("Grillhähnchen");
|
||||
assertThat(item.scoreDelta()).isCloseTo(1.5, within(0.001));
|
||||
assertThat(item.hasConflict()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void scoreDeltaIsSortedDescendingCleanBeforeConflicting() {
|
||||
// Clean recipe (scoreDelta = 0.0) should rank above conflicting (scoreDelta < 0).
|
||||
var plan = createPlan();
|
||||
var pastaTag = createTag("Pasta", "cuisine");
|
||||
var existingRecipe = createRecipe("Monday Pasta");
|
||||
addTag(existingRecipe, pastaTag);
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
|
||||
var cleanRecipe = createRecipe("Plain Rice");
|
||||
var conflictingRecipe = createRecipe("More Pasta");
|
||||
addTag(conflictingRecipe, pastaTag);
|
||||
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(existingRecipe, cleanRecipe, conflictingRecipe);
|
||||
stubNoCookingLogs();
|
||||
|
||||
SuggestionResponse result = planningService.getSuggestions(
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice");
|
||||
assertThat(result.suggestions().get(0).scoreDelta()).isEqualTo(0.0);
|
||||
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("More Pasta");
|
||||
assertThat(result.suggestions().get(1).scoreDelta()).isEqualTo(-1.5);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Category 2: Exclusion of In-Plan Recipes
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -402,8 +570,8 @@ class SuggestionsTest {
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
// B should rank higher (no tag penalty)
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice");
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -428,8 +596,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isEqualTo(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isEqualTo(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -453,8 +621,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
// No penalty — dietary not tracked
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// No penalty — dietary not tracked → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,8 +660,8 @@ class SuggestionsTest {
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto");
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -519,7 +687,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// Staples ignored → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,8 +716,8 @@ class SuggestionsTest {
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry");
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -566,7 +735,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// No penalty → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +801,7 @@ class SuggestionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rankingOrderShouldBeBySimulatedScoreDescending() {
|
||||
void rankingOrderShouldBeByScoreDeltaDescending() {
|
||||
var plan = createPlan();
|
||||
var pastaTag = createTag("Pasta", "cuisine");
|
||||
var tomato = createIngredient("Tomatoes", false);
|
||||
@@ -666,11 +836,11 @@ class SuggestionsTest {
|
||||
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta");
|
||||
assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta");
|
||||
|
||||
// Verify scores are strictly descending
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(1).simulatedScore())
|
||||
.isGreaterThan(result.suggestions().get(2).simulatedScore());
|
||||
// Verify scoreDelta is strictly descending
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(1).scoreDelta());
|
||||
assertThat(result.suggestions().get(1).scoreDelta())
|
||||
.isGreaterThan(result.suggestions().get(2).scoreDelta());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -688,8 +858,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).simulatedScore())
|
||||
.isEqualTo(result.suggestions().get(1).simulatedScore());
|
||||
assertThat(result.suggestions().get(0).scoreDelta())
|
||||
.isEqualTo(result.suggestions().get(1).scoreDelta());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,7 +896,7 @@ class SuggestionsTest {
|
||||
addTag(c1, pastaTag);
|
||||
addIngredient(c1, tomato);
|
||||
|
||||
// Candidate 2: Chicken only → protein repeat with Mon
|
||||
// Candidate 2: Chicken only → protein repeat with Mon (Mon→Wed not consecutive)
|
||||
var c2 = createRecipe("Chicken Salad");
|
||||
addTag(c2, chickenTag);
|
||||
|
||||
@@ -745,7 +915,7 @@ class SuggestionsTest {
|
||||
stubPlan(plan);
|
||||
stubDefaultConfig();
|
||||
stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5);
|
||||
// c1 was cooked recently
|
||||
// c1 was cooked recently (within 14-day window)
|
||||
stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3)));
|
||||
|
||||
// Slot date = Wednesday (adjacent to Tuesday)
|
||||
@@ -754,19 +924,20 @@ class SuggestionsTest {
|
||||
|
||||
assertThat(result.suggestions()).hasSize(5);
|
||||
|
||||
// c2, c4, c5 all score 10.0 (no penalties — Chicken Mon→Wed not consecutive)
|
||||
// currentScore = 10.0 (Mon+Tue plan: no consecutive conflicts between just those 2 slots)
|
||||
// c2, c4, c5: no additional conflicts → scoreDelta = 0.0
|
||||
var topThree = result.suggestions().subList(0, 3);
|
||||
assertThat(topThree).extracting(s -> s.recipe().name())
|
||||
.containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup");
|
||||
assertThat(topThree).allSatisfy(s -> assertThat(s.simulatedScore()).isEqualTo(10.0));
|
||||
assertThat(topThree).allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0));
|
||||
|
||||
// c3 (Cheese Omelette) has ingredient overlap Tue→Wed: -0.3
|
||||
// c3 (Cheese Omelette) has ingredient overlap Tue→Wed: scoreDelta = -0.3
|
||||
assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette");
|
||||
assertThat(result.suggestions().get(3).simulatedScore()).isCloseTo(9.7, within(0.001));
|
||||
assertThat(result.suggestions().get(3).scoreDelta()).isCloseTo(-0.3, within(0.001));
|
||||
|
||||
// c1 (Tomato Spaghetti) has recent repeat: -1.0
|
||||
// c1 (Tomato Spaghetti) has recent repeat: scoreDelta = -1.0
|
||||
assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti");
|
||||
assertThat(result.suggestions().get(4).simulatedScore()).isEqualTo(9.0);
|
||||
assertThat(result.suggestions().get(4).scoreDelta()).isEqualTo(-1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -800,7 +971,7 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1),
|
||||
List.of("Quick meal"), 5);
|
||||
|
||||
// Only quick recipes, ranked by variety
|
||||
// Only quick recipes, ranked by scoreDelta desc
|
||||
assertThat(result.suggestions()).hasSize(2);
|
||||
assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad");
|
||||
assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta");
|
||||
@@ -815,7 +986,7 @@ class SuggestionsTest {
|
||||
class EdgeCases {
|
||||
|
||||
@Test
|
||||
void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() {
|
||||
void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() {
|
||||
var plan = createPlan();
|
||||
var existingRecipe = createRecipe("Existing");
|
||||
addSlot(plan, existingRecipe, MONDAY);
|
||||
@@ -832,7 +1003,8 @@ class SuggestionsTest {
|
||||
HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5);
|
||||
|
||||
assertThat(result.suggestions()).hasSize(1);
|
||||
assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0);
|
||||
// No conflicts → scoreDelta = 0.0
|
||||
assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.recipeapp.planning;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.common.HouseholdRoleInterceptor;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -13,6 +15,8 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
@@ -49,6 +53,11 @@ class WeekPlanControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWeekPlanShouldReturn200() throws Exception {
|
||||
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
|
||||
@@ -153,7 +162,7 @@ class WeekPlanControllerTest {
|
||||
@Test
|
||||
void getSuggestionsShouldReturn200() throws Exception {
|
||||
var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null);
|
||||
var item = new SuggestionResponse.SuggestionItem(recipe, 9.5);
|
||||
var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false);
|
||||
var response = new SuggestionResponse(List.of(item));
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
@@ -166,7 +175,8 @@ class WeekPlanControllerTest {
|
||||
.param("slotDate", "2026-04-08"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry"))
|
||||
.andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5));
|
||||
.andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5))
|
||||
.andExpect(jsonPath("$.suggestions[0].hasConflict").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -182,4 +192,79 @@ class WeekPlanControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.score").value(7.5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getVarietyPreviewShouldReturn200() throws Exception {
|
||||
var recipeId = UUID.randomUUID();
|
||||
var response = new VarietyPreviewResponse(8.0, 9.0, 1.0);
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(planningService.getVarietyPreview(HOUSEHOLD_ID, PLAN_ID, recipeId, WEEK_START.plusDays(2)))
|
||||
.thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/v1/week-plans/{planId}/variety-preview", PLAN_ID)
|
||||
.principal(() -> "sarah@example.com")
|
||||
.param("recipeId", recipeId.toString())
|
||||
.param("date", "2026-04-08"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.currentScore").value(8.0))
|
||||
.andExpect(jsonPath("$.projectedScore").value(9.0))
|
||||
.andExpect(jsonPath("$.scoreDelta").value(1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addSlotShouldReturn403ForMemberRole() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
var recipeId = UUID.randomUUID();
|
||||
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/slots", PLAN_ID)
|
||||
.principal(() -> "member@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new CreateSlotRequest(WEEK_START.plusDays(1), recipeId))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSlotShouldReturn403ForMemberRole() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
var recipeId = UUID.randomUUID();
|
||||
mockMvcWithInterceptor.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
|
||||
.principal(() -> "member@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSlotShouldReturn403ForMemberRole() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
mockMvcWithInterceptor.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
|
||||
.principal(() -> "member@example.com"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.recipeapp.shopping;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.recipeapp.common.GlobalExceptionHandler;
|
||||
import com.recipeapp.common.HouseholdRoleInterceptor;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -12,10 +14,13 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -48,13 +53,46 @@ class ShoppingListControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 3, List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/v1/shopping-list")
|
||||
.param("weekStart", "2026-04-06")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(LIST_ID.toString()))
|
||||
.andExpect(jsonPath("$.filteredStaplesCount").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturn404WhenNoListExists() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/v1/shopping-list")
|
||||
.param("weekStart", "2026-04-06")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldReturn201() throws Exception {
|
||||
var recipeId = UUID.randomUUID();
|
||||
var item = new ShoppingListItemResponse(
|
||||
ITEM_ID, UUID.randomUUID(), "Tomatoes",
|
||||
new ShoppingListItemResponse.CategoryRef(UUID.randomUUID(), "Produce"),
|
||||
new BigDecimal("4.00"), "pcs", false, null, List.of(UUID.randomUUID()));
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of(item));
|
||||
new BigDecimal("4.00"), "pcs", false, null,
|
||||
List.of(new ShoppingListItemResponse.RecipeRef(recipeId, "Spaghetti")));
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 2, List.of(item));
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.generateFromPlan(HOUSEHOLD_ID, PLAN_ID)).thenReturn(response);
|
||||
@@ -68,7 +106,7 @@ class ShoppingListControllerTest {
|
||||
|
||||
@Test
|
||||
void getShoppingListShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, List.of());
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 0, List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getShoppingList(HOUSEHOLD_ID, LIST_ID)).thenReturn(response);
|
||||
@@ -84,7 +122,8 @@ class ShoppingListControllerTest {
|
||||
void checkItemShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListItemResponse(
|
||||
ITEM_ID, UUID.randomUUID(), "Tomatoes", null,
|
||||
new BigDecimal("4.00"), "pcs", true, USER_ID, List.of());
|
||||
new BigDecimal("4.00"), "pcs", true, USER_ID,
|
||||
List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(householdResolver.resolveUserId("sarah@example.com")).thenReturn(USER_ID);
|
||||
@@ -104,7 +143,8 @@ class ShoppingListControllerTest {
|
||||
void addItemShouldReturn201() throws Exception {
|
||||
var response = new ShoppingListItemResponse(
|
||||
ITEM_ID, null, "Paper towels", null,
|
||||
new BigDecimal("1"), "", false, null, List.of());
|
||||
new BigDecimal("1"), "", false, null,
|
||||
List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.addItem(eq(HOUSEHOLD_ID), eq(LIST_ID), any(AddItemRequest.class)))
|
||||
@@ -128,4 +168,30 @@ class ShoppingListControllerTest {
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addItemShouldReturn400WhenCustomNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/v1/shopping-lists/{id}/items", LIST_ID)
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new AddItemRequest(null, " ", new BigDecimal("1"), ""))))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldReturn403ForNonPlanner() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("member@example.com", null));
|
||||
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
|
||||
|
||||
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(shoppingListController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
|
||||
.build();
|
||||
|
||||
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/shopping-list", PLAN_ID)
|
||||
.principal(() -> "member@example.com"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.recipeapp.planning.WeekPlanRepository;
|
||||
import com.recipeapp.planning.entity.WeekPlan;
|
||||
import com.recipeapp.planning.entity.WeekPlanSlot;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import com.recipeapp.recipe.entity.IngredientCategory;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
@@ -39,6 +40,7 @@ class ShoppingServiceTest {
|
||||
@Mock private HouseholdRepository householdRepository;
|
||||
@Mock private IngredientRepository ingredientRepository;
|
||||
@Mock private UserAccountRepository userAccountRepository;
|
||||
@Mock private RecipeRepository recipeRepository;
|
||||
|
||||
@InjectMocks private ShoppingService shoppingService;
|
||||
|
||||
@@ -90,6 +92,46 @@ class ShoppingServiceTest {
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
}
|
||||
|
||||
// ── Get by week start ──
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturnListForGivenWeek() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var list = testShoppingList(household, plan);
|
||||
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.of(list));
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result.id()).isEqualTo(list.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldDefaultToCurrentWeekWhenNull() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var list = testShoppingList(household, plan);
|
||||
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(eq(HOUSEHOLD_ID), any(LocalDate.class)))
|
||||
.thenReturn(Optional.of(list));
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, null);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturnNullWhenNoListExists() {
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
// ── Generate ──
|
||||
|
||||
@Test
|
||||
@@ -119,26 +161,84 @@ class ShoppingServiceTest {
|
||||
plan.getSlots().add(slot2);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.items()).hasSize(2); // tomatoes + cheese (salt filtered)
|
||||
assertThat(result.filteredStaplesCount()).isEqualTo(1); // salt
|
||||
|
||||
var tomatoItem = result.items().stream()
|
||||
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(tomatoItem.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); // 2 + 3
|
||||
assertThat(tomatoItem.sourceRecipes()).hasSize(2);
|
||||
assertThat(tomatoItem.sourceRecipes().get(0).name()).isNotNull();
|
||||
|
||||
var cheeseItem = result.items().stream()
|
||||
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldMergeWhenListAlreadyExists() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var existingList = testShoppingList(household, plan);
|
||||
|
||||
// Existing generated item: 2 tomatoes
|
||||
var tomato = testIngredient(household, "Tomatoes", false);
|
||||
var existingItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
|
||||
existingItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
|
||||
existingList.getItems().add(existingItem);
|
||||
|
||||
// Existing custom item (should be preserved)
|
||||
var customItem = new ShoppingListItem(existingList, null, "Paper towels",
|
||||
new BigDecimal("1"), "", new UUID[0]);
|
||||
setId(customItem, ShoppingListItem.class, UUID.randomUUID());
|
||||
customItem.setChecked(true);
|
||||
existingList.getItems().add(customItem);
|
||||
|
||||
// New plan: 5 tomatoes + cheese (tomato quantity updated, cheese added)
|
||||
var recipe = testRecipe(household, "Pasta");
|
||||
var cheese = testIngredient(household, "Cheese", false);
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("5.00"), "pcs", (short) 1));
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, cheese, new BigDecimal("200.00"), "g", (short) 2));
|
||||
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, plan.getWeekStart()))
|
||||
.thenReturn(Optional.of(existingList));
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
// Should have 3 items: tomato (updated), cheese (new), paper towels (preserved custom)
|
||||
assertThat(result.items()).hasSize(3);
|
||||
|
||||
var tomatoResult = result.items().stream()
|
||||
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(tomatoResult.quantity()).isEqualByComparingTo(new BigDecimal("5.00"));
|
||||
|
||||
var cheeseResult = result.items().stream()
|
||||
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(cheeseResult.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
|
||||
|
||||
// Custom item preserved with check state
|
||||
var customResult = result.items().stream()
|
||||
.filter(i -> "Paper towels".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(customResult.isChecked()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldThrowWhenPlanNotFound() {
|
||||
var planId = UUID.randomUUID();
|
||||
@@ -164,6 +264,7 @@ class ShoppingServiceTest {
|
||||
ShoppingListResponse result = shoppingService.getShoppingList(HOUSEHOLD_ID, list.getId());
|
||||
|
||||
assertThat(result.id()).isEqualTo(list.getId());
|
||||
assertThat(result.generatedAt()).isNotNull();
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
|
||||
}
|
||||
@@ -367,6 +468,97 @@ class ShoppingServiceTest {
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── Generate removes stale items ──
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldRemoveStaleGeneratedItems() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var existingList = testShoppingList(household, plan);
|
||||
|
||||
var tomato = testIngredient(household, "Tomatoes", false);
|
||||
var onion = testIngredient(household, "Onions", false);
|
||||
|
||||
// Existing list has both tomatoes and onions (generated)
|
||||
var tomatoItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
|
||||
tomatoItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
|
||||
existingList.getItems().add(tomatoItem);
|
||||
|
||||
var onionItem = testItem(existingList, onion, new BigDecimal("1.00"), "pcs");
|
||||
onionItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
|
||||
existingList.getItems().add(onionItem);
|
||||
|
||||
// New plan only has tomatoes — onions removed from recipes
|
||||
var recipe = testRecipe(household, "Sauce");
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("3.00"), "pcs", (short) 1));
|
||||
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.of(existingList));
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().getFirst().name()).isEqualTo("Tomatoes");
|
||||
}
|
||||
|
||||
// ── Source recipes deduplication ──
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldDeduplicateSourceRecipesWhenSameRecipeInTwoSlots() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var recipe = testRecipe(household, "Pasta");
|
||||
var tomato = testIngredient(household, "Tomatoes", false);
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("2.00"), "pcs", (short) 1));
|
||||
|
||||
// Same recipe in two slots
|
||||
var slot1 = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot1, WeekPlanSlot.class, UUID.randomUUID());
|
||||
var slot2 = new WeekPlanSlot(plan, recipe, WEEK_START.plusDays(2));
|
||||
setId(slot2, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot1);
|
||||
plan.getSlots().add(slot2);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().getFirst().sourceRecipes()).hasSize(1); // deduplicated
|
||||
}
|
||||
|
||||
// ── checkItem household isolation ──
|
||||
|
||||
@Test
|
||||
void checkItemShouldThrowWhenHouseholdMismatch() {
|
||||
var otherHousehold = new Household("Other family", null);
|
||||
setId(otherHousehold, Household.class, UUID.randomUUID());
|
||||
var plan = new WeekPlan(otherHousehold, WEEK_START);
|
||||
setId(plan, WeekPlan.class, UUID.randomUUID());
|
||||
var list = new ShoppingList(otherHousehold, plan);
|
||||
setId(list, ShoppingList.class, UUID.randomUUID());
|
||||
|
||||
when(shoppingListRepository.findById(list.getId())).thenReturn(Optional.of(list));
|
||||
|
||||
assertThatThrownBy(() -> shoppingService.checkItem(
|
||||
HOUSEHOLD_ID, list.getId(), UUID.randomUUID(), new CheckItemRequest(true), UUID.randomUUID()))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
// ── Generate from plan with empty slots ──
|
||||
|
||||
@Test
|
||||
@@ -376,9 +568,11 @@ class ShoppingServiceTest {
|
||||
// no slots added
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('auth guard (hooks.server.ts handle)', () => {
|
||||
displayName: 'Max',
|
||||
householdId: 'h1',
|
||||
householdName: 'Familie Müller',
|
||||
householdRole: 'planer',
|
||||
householdRole: 'planner',
|
||||
email: 'max@example.com',
|
||||
systemRole: 'user'
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.benutzer = {
|
||||
id: user.id!,
|
||||
name: user.displayName!,
|
||||
rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
|
||||
rolle: user.householdRole === 'planner' ? 'planer' : 'mitglied'
|
||||
};
|
||||
event.locals.haushalt = {
|
||||
id: user.householdId ?? undefined,
|
||||
|
||||
File diff suppressed because one or more lines are too long
52
frontend/src/lib/api/schema.d.ts
vendored
52
frontend/src/lib/api/schema.d.ts
vendored
@@ -452,6 +452,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/shopping-list": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getByWeekStart"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/ingredients": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -624,6 +640,11 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
recipeId: string;
|
||||
};
|
||||
RecipeRef: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
ShoppingListItemResponse: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
@@ -636,13 +657,17 @@ export interface components {
|
||||
isChecked?: boolean;
|
||||
/** Format: uuid */
|
||||
checkedBy?: string;
|
||||
sourceRecipes?: string[];
|
||||
sourceRecipes?: components["schemas"]["RecipeRef"][];
|
||||
};
|
||||
ShoppingListResponse: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
/** Format: uuid */
|
||||
weekPlanId?: string;
|
||||
/** Format: date-time */
|
||||
generatedAt?: string;
|
||||
/** Format: int32 */
|
||||
filteredStaplesCount?: number;
|
||||
items?: components["schemas"]["ShoppingListItemResponse"][];
|
||||
};
|
||||
TagCreateRequest: {
|
||||
@@ -889,7 +914,8 @@ export interface components {
|
||||
SuggestionItem: {
|
||||
recipe?: components["schemas"]["SlotRecipe"];
|
||||
/** Format: double */
|
||||
simulatedScore?: number;
|
||||
scoreDelta?: number;
|
||||
hasConflict?: boolean;
|
||||
};
|
||||
SuggestionResponse: {
|
||||
suggestions?: components["schemas"]["SuggestionItem"][];
|
||||
@@ -1902,6 +1928,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getByWeekStart: {
|
||||
parameters: {
|
||||
query?: {
|
||||
weekStart?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ShoppingListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchIngredients: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
113
frontend/src/lib/planner/DayMealCard.svelte
Normal file
113
frontend/src/lib/planner/DayMealCard.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: SlotRecipe | null;
|
||||
}
|
||||
|
||||
let {
|
||||
slot,
|
||||
isToday = false,
|
||||
isSelected = false,
|
||||
readonly = false,
|
||||
onaddrecipe,
|
||||
onactionsheet
|
||||
}: {
|
||||
slot: Slot;
|
||||
isToday?: boolean;
|
||||
isSelected?: boolean;
|
||||
readonly?: boolean;
|
||||
onaddrecipe?: () => void;
|
||||
onactionsheet?: () => void;
|
||||
} = $props();
|
||||
|
||||
let actionSheetMode = $derived(!!onactionsheet && !!slot.recipe);
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
|
||||
slot.recipe?.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
|
||||
let borderClass = $derived(
|
||||
isToday
|
||||
? 'border-[var(--yellow)] bg-[var(--yellow-tint)]'
|
||||
: isSelected
|
||||
? 'border-[var(--green)] bg-[var(--green-tint)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet recipeInfo()}
|
||||
<h3 class="font-[var(--font-display)] text-[20px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
{slot.recipe?.name ?? ''}
|
||||
</h3>
|
||||
{#if metadata}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if actionSheetMode}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="day-meal-card"
|
||||
data-today={isToday}
|
||||
data-selected={isSelected}
|
||||
onclick={onactionsheet}
|
||||
class="w-full text-left rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||
>
|
||||
{@render recipeInfo()}
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
data-testid="day-meal-card"
|
||||
data-today={isToday}
|
||||
data-selected={isSelected}
|
||||
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
{@render recipeInfo()}
|
||||
|
||||
{#if !readonly}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a
|
||||
href="/recipes/{slot.recipe.id}/cook"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Jetzt kochen
|
||||
</a>
|
||||
{#if onaddrecipe}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaddrecipe}
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||
>
|
||||
Tauschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if !readonly && onaddrecipe}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaddrecipe}
|
||||
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
118
frontend/src/lib/planner/DayMealCard.test.ts
Normal file
118
frontend/src/lib/planner/DayMealCard.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import DayMealCard from './DayMealCard.svelte';
|
||||
|
||||
const slot = {
|
||||
id: 's1',
|
||||
slotDate: '2026-03-30',
|
||||
recipe: { id: 'r1', name: 'Pasta Bolognese', effort: 'Easy', cookTimeMin: 30 }
|
||||
};
|
||||
|
||||
describe('DayMealCard', () => {
|
||||
it('renders recipe name', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Jetzt kochen link and Tauschen button when not readonly and onaddrecipe provided', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe: vi.fn() } });
|
||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
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 } });
|
||||
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides action links when readonly', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: true, onaddrecipe: vi.fn() } });
|
||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('applies today styling when isToday is true', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: true, readonly: false } });
|
||||
const card = screen.getByTestId('day-meal-card');
|
||||
expect(card.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('applies selected styling when isSelected is true and not today', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, isSelected: true, readonly: false } });
|
||||
const card = screen.getByTestId('day-meal-card');
|
||||
expect(card.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders empty state when slot has no recipe', () => {
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||
expect(screen.getByText(/Kein Gericht/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows cook time and effort metadata', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByText(/30 Min/)).toBeTruthy();
|
||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||
});
|
||||
|
||||
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 } });
|
||||
expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull();
|
||||
});
|
||||
|
||||
describe('onactionsheet prop (mobile full-card tap target)', () => {
|
||||
it('card renders as a button when onactionsheet provided and recipe exists', () => {
|
||||
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
|
||||
const card = screen.getByRole('button', { name: /Pasta Bolognese/i });
|
||||
expect(card).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking card calls onactionsheet', async () => {
|
||||
const onactionsheet = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(DayMealCard, { props: { slot, onactionsheet } });
|
||||
await user.click(screen.getByRole('button', { name: /Pasta Bolognese/i }));
|
||||
expect(onactionsheet).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('inline Jetzt kochen and Tauschen buttons are hidden when onactionsheet provided', () => {
|
||||
render(DayMealCard, { props: { slot, onactionsheet: vi.fn() } });
|
||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to normal rendering when onactionsheet not provided', () => {
|
||||
render(DayMealCard, { props: { slot, readonly: false, onaddrecipe: vi.fn() } });
|
||||
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
|
||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('empty slot does not render card as button even when onactionsheet provided', () => {
|
||||
const emptySlot = { id: 's2', slotDate: '2026-03-31', recipe: null };
|
||||
render(DayMealCard, { props: { slot: emptySlot, onactionsheet: vi.fn(), onaddrecipe: vi.fn() } });
|
||||
expect(screen.queryByRole('button', { name: /Pasta Bolognese/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
168
frontend/src/lib/planner/DayPicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { weekDays, prevWeek, nextWeek, formatDayAbbr, formatWeekRange } from './week';
|
||||
|
||||
interface Slot {
|
||||
id: string;
|
||||
slotDate: string;
|
||||
recipe: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
recipeName,
|
||||
recipeId,
|
||||
planId,
|
||||
weekStart,
|
||||
today,
|
||||
slots = [],
|
||||
onconfirm,
|
||||
onweekchange
|
||||
}: {
|
||||
recipeName: string;
|
||||
recipeId: string;
|
||||
planId: string;
|
||||
weekStart: string;
|
||||
today: string;
|
||||
slots: Slot[];
|
||||
onconfirm: (result: { date: string; slotId: string | null }) => void;
|
||||
onweekchange: (newWeekStart: string) => void;
|
||||
} = $props();
|
||||
|
||||
let selectedDate = $state<string | null>(null);
|
||||
|
||||
const slotMap = $derived(
|
||||
new Map(slots.map((s) => [s.slotDate, s]))
|
||||
);
|
||||
|
||||
const days = $derived(weekDays(weekStart));
|
||||
|
||||
function chipState(date: string): string {
|
||||
const isSelected = selectedDate === date;
|
||||
const slot = slotMap.get(date);
|
||||
const hasFilled = slot?.recipe != null;
|
||||
|
||||
if (isSelected) {
|
||||
return hasFilled ? 'sel-filled' : 'sel-empty';
|
||||
}
|
||||
if (date === today) return 'today';
|
||||
return hasFilled ? 'filled' : 'empty';
|
||||
}
|
||||
|
||||
const selectedSlot = $derived(selectedDate ? slotMap.get(selectedDate) : undefined);
|
||||
const existingRecipeName = $derived(selectedSlot?.recipe?.name ?? null);
|
||||
const existingSlotId = $derived(selectedSlot?.id ?? null);
|
||||
|
||||
function chipStyle(state: string): string {
|
||||
switch (state) {
|
||||
case 'empty':
|
||||
return 'border-style: dashed; border-color: var(--green-light); background: var(--green-tint);';
|
||||
case 'filled':
|
||||
return 'border-color: var(--color-border); background: var(--color-surface);';
|
||||
case 'today':
|
||||
return 'border-color: var(--yellow); background: var(--yellow-tint);';
|
||||
case 'sel-empty':
|
||||
return 'border: 2px solid var(--green-dark); background: var(--green-tint);';
|
||||
case 'sel-filled':
|
||||
return 'border: 2px solid var(--orange-dark); background: var(--orange-tint);';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChipClick(date: string) {
|
||||
selectedDate = date;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedDate) return;
|
||||
onconfirm({ date: selectedDate, slotId: existingSlotId });
|
||||
}
|
||||
|
||||
function dayNumber(date: string): string {
|
||||
return date.slice(-2).replace(/^0/, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
Tag wählen
|
||||
</p>
|
||||
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||
{recipeName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Week navigation -->
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--color-border);"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorherige Woche"
|
||||
onclick={() => onweekchange(prevWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span style="font-size: 12px; font-weight: 500; color: var(--color-text);">
|
||||
{formatWeekRange(weekStart)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächste Woche"
|
||||
onclick={() => onweekchange(nextWeek(weekStart))}
|
||||
style="background: none; border: none; cursor: pointer; padding: 4px 6px; font-size: 14px; color: var(--color-text-muted); border-radius: var(--radius-md);"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Day chips -->
|
||||
<div
|
||||
style="display: flex; gap: 6px; padding: 10px 12px; overflow-x: auto;"
|
||||
>
|
||||
{#each days as date (date)}
|
||||
{@const state = chipState(date)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chip-{date}"
|
||||
data-state={state}
|
||||
onclick={() => handleChipClick(date)}
|
||||
style="flex: 1; min-width: 36px; padding: 6px 4px; border-radius: var(--radius-md); border: 1px solid transparent; cursor: pointer; text-align: center; font-family: var(--font-sans); {chipStyle(state)}"
|
||||
>
|
||||
<span style="display: block; font-size: 9px; font-weight: 500; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em;">
|
||||
{formatDayAbbr(date, 'narrow')}
|
||||
</span>
|
||||
<span style="display: block; font-size: 13px; font-weight: 600; color: var(--color-text); margin-top: 2px;">
|
||||
{dayNumber(date)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Replace warning -->
|
||||
{#if selectedDate && existingRecipeName}
|
||||
<div
|
||||
data-testid="replace-warning"
|
||||
style="margin: 0 12px 10px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--orange-tint); border: 1px solid var(--orange-dark); font-size: 11px; color: var(--color-text);"
|
||||
>
|
||||
Ersetzt <strong>{existingRecipeName}</strong> an diesem Tag.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirm button -->
|
||||
<div style="padding: 0 12px 12px;">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="confirm-btn"
|
||||
disabled={!selectedDate}
|
||||
onclick={handleConfirm}
|
||||
style="width: 100%; padding: 9px 12px; font-family: var(--font-sans); font-size: 13px; font-weight: 600; border-radius: var(--radius-md); border: none; cursor: {selectedDate ? 'pointer' : 'not-allowed'}; background: {selectedDate ? 'var(--green)' : 'var(--color-border)'}; color: {selectedDate ? '#fff' : 'var(--color-text-muted)'};"
|
||||
>
|
||||
Einplanen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
134
frontend/src/lib/planner/DayPicker.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DayPicker from './DayPicker.svelte';
|
||||
|
||||
const weekStart = '2026-03-30'; // Monday
|
||||
const today = '2026-04-01'; // Wednesday
|
||||
|
||||
// Mo: filled, Di: filled (today), Mi: filled, Do: empty, Fr: filled, Sa: empty, So: filled
|
||||
const slots = [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } },
|
||||
{ id: 's2', slotDate: '2026-04-01', recipe: { id: 'r2', name: 'Curry', effort: 'easy' } },
|
||||
{ id: 's3', slotDate: '2026-04-02', recipe: { id: 'r3', name: 'Risotto', effort: 'medium' } },
|
||||
{ id: 's5', slotDate: '2026-04-04', recipe: { id: 'r5', name: 'Suppe', effort: 'easy' } },
|
||||
{ id: 's7', slotDate: '2026-04-06', recipe: { id: 'r7', name: 'Stir Fry', effort: 'easy' } }
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
recipeName: 'Mushroom Risotto',
|
||||
recipeId: 'recipe-42',
|
||||
planId: 'plan-1',
|
||||
weekStart,
|
||||
today,
|
||||
slots,
|
||||
onconfirm: vi.fn(),
|
||||
onweekchange: vi.fn()
|
||||
};
|
||||
|
||||
describe('DayPicker', () => {
|
||||
it('shows recipe name in header', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
expect(screen.getByText('Mushroom Risotto')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows 7 day chips', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const chips = screen.getAllByTestId(/^chip-/);
|
||||
expect(chips).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('marks empty slot chips with data-state="empty"', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
// Do (2026-04-03) and Sa (2026-04-05) are empty
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
expect(doChip.getAttribute('data-state')).toBe('empty');
|
||||
});
|
||||
|
||||
it('marks filled slot chips with data-state="filled"', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
expect(moChip.getAttribute('data-state')).toBe('filled');
|
||||
});
|
||||
|
||||
it('marks today chip with data-state="today"', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const todayChip = screen.getByTestId('chip-2026-04-01');
|
||||
expect(todayChip.getAttribute('data-state')).toBe('today');
|
||||
});
|
||||
|
||||
it('selecting an empty chip changes its state to sel-empty', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
await userEvent.click(doChip);
|
||||
expect(doChip.getAttribute('data-state')).toBe('sel-empty');
|
||||
});
|
||||
|
||||
it('selecting a filled chip changes its state to sel-filled', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
await userEvent.click(moChip);
|
||||
expect(moChip.getAttribute('data-state')).toBe('sel-filled');
|
||||
});
|
||||
|
||||
it('shows replace warning when filled chip is selected', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
await userEvent.click(moChip);
|
||||
expect(screen.getByTestId('replace-warning')).toBeTruthy();
|
||||
expect(screen.getByText(/Pasta/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show replace warning when empty chip is selected', async () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
await userEvent.click(doChip);
|
||||
expect(screen.queryByTestId('replace-warning')).toBeNull();
|
||||
});
|
||||
|
||||
it('confirm button is disabled when no chip is selected', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
const btn = screen.getByTestId('confirm-btn');
|
||||
expect(btn.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onconfirm with date and null slotId when empty chip confirmed', async () => {
|
||||
const onconfirm = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onconfirm } });
|
||||
const doChip = screen.getByTestId('chip-2026-04-03');
|
||||
await userEvent.click(doChip);
|
||||
const btn = screen.getByTestId('confirm-btn');
|
||||
await userEvent.click(btn);
|
||||
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-04-03', slotId: null });
|
||||
});
|
||||
|
||||
it('calls onconfirm with date and slotId when filled chip confirmed', async () => {
|
||||
const onconfirm = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onconfirm } });
|
||||
const moChip = screen.getByTestId('chip-2026-03-30');
|
||||
await userEvent.click(moChip);
|
||||
const btn = screen.getByTestId('confirm-btn');
|
||||
await userEvent.click(btn);
|
||||
expect(onconfirm).toHaveBeenCalledWith({ date: '2026-03-30', slotId: 's1' });
|
||||
});
|
||||
|
||||
it('shows prev/next week navigation buttons', () => {
|
||||
render(DayPicker, { props: baseProps });
|
||||
expect(screen.getByRole('button', { name: /Vorherige Woche/ })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Nächste Woche/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onweekchange with prev week when prev button clicked', async () => {
|
||||
const onweekchange = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onweekchange } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /Vorherige Woche/ }));
|
||||
expect(onweekchange).toHaveBeenCalledWith('2026-03-23');
|
||||
});
|
||||
|
||||
it('calls onweekchange with next week when next button clicked', async () => {
|
||||
const onweekchange = vi.fn();
|
||||
render(DayPicker, { props: { ...baseProps, onweekchange } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /Nächste Woche/ }));
|
||||
expect(onweekchange).toHaveBeenCalledWith('2026-04-06');
|
||||
});
|
||||
});
|
||||
66
frontend/src/lib/planner/EffortBar.svelte
Normal file
66
frontend/src/lib/planner/EffortBar.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
easy,
|
||||
medium,
|
||||
hard
|
||||
}: {
|
||||
easy: number;
|
||||
medium: number;
|
||||
hard: number;
|
||||
} = $props();
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Labels below the bar -->
|
||||
<div class="space-y-2">
|
||||
<!-- Bar segments -->
|
||||
<div class="flex h-[10px] overflow-hidden rounded-full">
|
||||
{#if easy > 0}
|
||||
<div
|
||||
class="h-full bg-[var(--green)]"
|
||||
style="flex: {easy}"
|
||||
></div>
|
||||
{/if}
|
||||
{#if medium > 0}
|
||||
<div
|
||||
class="h-full bg-[var(--yellow)]"
|
||||
style="flex: {medium}"
|
||||
></div>
|
||||
{/if}
|
||||
{#if hard > 0}
|
||||
<div
|
||||
class="h-full bg-[var(--color-error)]"
|
||||
style="flex: {hard}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<div class="flex gap-4">
|
||||
{#if easy > 0}
|
||||
<span
|
||||
data-testid="effort-easy"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--green-dark)]"
|
||||
>
|
||||
Einfach ×{easy}
|
||||
</span>
|
||||
{/if}
|
||||
{#if medium > 0}
|
||||
<span
|
||||
data-testid="effort-medium"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]"
|
||||
>
|
||||
Mittel ×{medium}
|
||||
</span>
|
||||
{/if}
|
||||
{#if hard > 0}
|
||||
<span
|
||||
data-testid="effort-hard"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
|
||||
>
|
||||
Aufwändig ×{hard}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import EffortBar from './EffortBar.svelte';
|
||||
|
||||
describe('EffortBar', () => {
|
||||
it('renders segment for easy effort', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-easy').textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('renders segment for medium effort', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-medium').textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('renders segment for hard effort', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-hard').textContent).toContain('1');
|
||||
});
|
||||
|
||||
it('hides zero-count segments', () => {
|
||||
render(EffortBar, { props: { easy: 7, medium: 0, hard: 0 } });
|
||||
expect(screen.queryByTestId('effort-medium')).toBeNull();
|
||||
expect(screen.queryByTestId('effort-hard')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders label with ×N count', () => {
|
||||
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
|
||||
expect(screen.getByTestId('effort-easy').textContent).toContain('×3');
|
||||
});
|
||||
|
||||
it('renders no segments when all counts are zero', () => {
|
||||
render(EffortBar, { props: { easy: 0, medium: 0, hard: 0 } });
|
||||
expect(screen.queryByTestId('effort-easy')).toBeNull();
|
||||
expect(screen.queryByTestId('effort-medium')).toBeNull();
|
||||
expect(screen.queryByTestId('effort-hard')).toBeNull();
|
||||
});
|
||||
});
|
||||
122
frontend/src/lib/planner/MealActionSheet.svelte
Normal file
122
frontend/src/lib/planner/MealActionSheet.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe: SlotRecipe | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
slot: Slot;
|
||||
onswap: () => void;
|
||||
oncancel: () => void;
|
||||
onremove?: () => void;
|
||||
}
|
||||
|
||||
let { open, slot, onswap, oncancel, onremove }: Props = $props();
|
||||
|
||||
const meta = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (slot.recipe?.cookTimeMin != null) parts.push(`${slot.recipe.cookTimeMin} min`);
|
||||
if (slot.recipe?.effort) parts.push(slot.recipe.effort);
|
||||
return parts.join(' · ');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') oncancel();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="presentation"
|
||||
data-testid="sheet-backdrop"
|
||||
style="position:fixed;inset:0;z-index:50;background:rgba(28,28,24,0.4)"
|
||||
onclick={oncancel}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
style="position:absolute;bottom:0;left:0;right:0;background:var(--color-page);border-radius:var(--radius-xl) var(--radius-xl) 0 0;box-shadow:var(--shadow-overlay)"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div style="display:flex;justify-content:center;margin-top:12px">
|
||||
<div style="width:32px;height:4px;background:var(--color-border);border-radius:9999px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Meal title -->
|
||||
<p style="font-family:var(--font-display);font-size:15px;font-weight:500;color:var(--color-text);padding:0 16px;margin:12px 0 4px">
|
||||
{slot.recipe?.name ?? ''}
|
||||
</p>
|
||||
|
||||
<!-- Metadata -->
|
||||
{#if meta}
|
||||
<p style="font-family:var(--font-sans);font-size:11px;color:var(--color-text-muted);padding:0 16px 12px;margin:0">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="padding:0 16px 16px;display:flex;flex-direction:column;gap:6px">
|
||||
<button
|
||||
type="button"
|
||||
style="width:100%;background:var(--orange-tint);border:1px solid #FBCDA4;color:var(--orange-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
|
||||
onclick={onswap}
|
||||
>
|
||||
↻ Gericht tauschen
|
||||
</button>
|
||||
|
||||
{#if onremove}
|
||||
<button
|
||||
type="button"
|
||||
style="width:100%;background:var(--color-error, #d9534f);border:1px solid var(--color-error, #d9534f);color:#fff;font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;cursor:pointer"
|
||||
onclick={onremove}
|
||||
>
|
||||
✕ Gericht entfernen
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if slot.recipe}
|
||||
<a
|
||||
href="/recipes/{slot.recipe.id}/cook"
|
||||
style="display:block;width:100%;background:var(--green-tint);border:1px solid var(--green-light);color:var(--green-dark);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
|
||||
>
|
||||
🍳 Jetzt kochen
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/recipes/{slot.recipe.id}"
|
||||
style="display:block;width:100%;background:var(--color-subtle);border:1px solid var(--color-border);color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;font-weight:500;border-radius:var(--radius-lg);padding:12px;text-align:center;box-sizing:border-box;text-decoration:none"
|
||||
>
|
||||
👁 Rezept ansehen
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style="width:100%;background:none;border:none;color:var(--color-text-muted);font-family:var(--font-sans);font-size:13px;text-align:center;cursor:pointer;padding:12px"
|
||||
onclick={oncancel}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
95
frontend/src/lib/planner/MealActionSheet.test.ts
Normal file
95
frontend/src/lib/planner/MealActionSheet.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import MealActionSheet from './MealActionSheet.svelte';
|
||||
|
||||
const slot = {
|
||||
id: 's1',
|
||||
slotDate: '2026-04-08',
|
||||
recipe: { id: 'r1', name: 'Tomato pasta', effort: 'easy', cookTimeMin: 45 }
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
slot,
|
||||
onswap: vi.fn(),
|
||||
oncancel: vi.fn(),
|
||||
onremove: vi.fn()
|
||||
};
|
||||
|
||||
describe('MealActionSheet', () => {
|
||||
it('renders meal title', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
expect(screen.getByText('Tomato pasta')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders meal metadata', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
expect(screen.getByText(/45 min/i)).toBeTruthy();
|
||||
expect(screen.getByText(/easy/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders all 5 action buttons', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
expect(screen.getByRole('button', { name: /Gericht tauschen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /Rezept ansehen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Entfernen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking Entfernen calls onremove', async () => {
|
||||
const onremove = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(MealActionSheet, { props: { ...baseProps, onremove } });
|
||||
await user.click(screen.getByRole('button', { name: /Entfernen/i }));
|
||||
expect(onremove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not render Entfernen button when onremove is not provided', () => {
|
||||
const { onremove: _, ...propsWithoutRemove } = baseProps;
|
||||
render(MealActionSheet, { props: propsWithoutRemove });
|
||||
expect(screen.queryByRole('button', { name: /Entfernen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('Jetzt kochen links to the cook route', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
const link = screen.getByRole('link', { name: /Jetzt kochen/i });
|
||||
expect(link.getAttribute('href')).toBe('/recipes/r1/cook');
|
||||
});
|
||||
|
||||
it('Rezept ansehen links to the recipe detail route', () => {
|
||||
render(MealActionSheet, { props: baseProps });
|
||||
const link = screen.getByRole('link', { name: /Rezept ansehen/i });
|
||||
expect(link.getAttribute('href')).toBe('/recipes/r1');
|
||||
});
|
||||
|
||||
it('clicking Gericht tauschen calls onswap', async () => {
|
||||
const onswap = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(MealActionSheet, { props: { ...baseProps, onswap } });
|
||||
await user.click(screen.getByRole('button', { name: /Gericht tauschen/i }));
|
||||
expect(onswap).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clicking Abbrechen calls oncancel', async () => {
|
||||
const oncancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(MealActionSheet, { props: { ...baseProps, oncancel } });
|
||||
await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
|
||||
expect(oncancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('clicking backdrop calls oncancel', async () => {
|
||||
const oncancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(MealActionSheet, { props: { ...baseProps, oncancel } });
|
||||
await user.click(screen.getByTestId('sheet-backdrop'));
|
||||
expect(oncancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(MealActionSheet, { props: { ...baseProps, open: false } });
|
||||
expect(screen.queryByText('Tomato pasta')).toBeNull();
|
||||
});
|
||||
});
|
||||
244
frontend/src/lib/planner/RecipePicker.svelte
Normal file
244
frontend/src/lib/planner/RecipePicker.svelte
Normal file
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import type { Recipe, Suggestion } from '$lib/planner/types';
|
||||
|
||||
let {
|
||||
planId,
|
||||
date,
|
||||
dateLabel,
|
||||
suggestions = [],
|
||||
allRecipes = [],
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
excludeRecipeId,
|
||||
replacingRecipe,
|
||||
onpick
|
||||
}: {
|
||||
planId: string;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
suggestions: Suggestion[];
|
||||
allRecipes: Recipe[];
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
excludeRecipeId?: string;
|
||||
replacingRecipe?: { name: string; meta?: string };
|
||||
onpick: (recipeId: string, recipeName: string) => void;
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let topRecommendations = $derived(
|
||||
suggestions
|
||||
.filter((s) => s.scoreDelta > 0 && s.recipe.id !== excludeRecipeId)
|
||||
.slice(0, 5)
|
||||
);
|
||||
|
||||
let scoreMap = $derived(
|
||||
new Map(suggestions.map((s) => [s.recipe.id, s]))
|
||||
);
|
||||
|
||||
let baseRecipes = $derived(
|
||||
excludeRecipeId ? allRecipes.filter((r) => r.id !== excludeRecipeId) : allRecipes
|
||||
);
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
searchQuery.trim() === ''
|
||||
? baseRecipes
|
||||
: baseRecipes.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>
|
||||
|
||||
{#snippet scoreBadge(recipeId: string, delta: number, hasConflict: boolean)}
|
||||
{#if delta > 0}
|
||||
<span
|
||||
data-testid="badge-{recipeId}"
|
||||
data-type="good"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
||||
>
|
||||
↑ +{delta.toFixed(1)} Punkte
|
||||
</span>
|
||||
{:else if hasConflict}
|
||||
<span
|
||||
data-testid="badge-{recipeId}"
|
||||
data-type="bad"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--red-tint, #fdecea); color: var(--color-error, #d9534f);"
|
||||
>
|
||||
↓ {delta.toFixed(1)} Punkte
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="badge-{recipeId}"
|
||||
data-type="neutral"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
|
||||
>
|
||||
Kein Einfluss
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header (hidden in swap context — the panel/sheet title already provides context) -->
|
||||
{#if !replacingRecipe}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<!-- Wird ersetzt banner (swap context) -->
|
||||
{#if replacingRecipe}
|
||||
<div style="background: var(--orange-tint); border-bottom: 1px solid #FBCDA4; padding: 8px 12px;">
|
||||
<p style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 2px 0; font-family: var(--font-sans);">
|
||||
Wird ersetzt
|
||||
</p>
|
||||
<span
|
||||
data-testid="replacing-name"
|
||||
title={replacingRecipe.name}
|
||||
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 13px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
|
||||
>
|
||||
{replacingRecipe.name}{#if replacingRecipe.meta} · {replacingRecipe.meta}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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 isLoading}
|
||||
<div data-testid="suggestions-loading">
|
||||
{#each [1, 2, 3] as i (i)}
|
||||
<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;">
|
||||
<div
|
||||
style="height: 12px; width: 60%; border-radius: 3px; background: var(--color-subtle); animation: pulse 1.5s ease-in-out infinite;"
|
||||
></div>
|
||||
<div
|
||||
style="height: 9px; width: 35%; border-radius: 3px; background: var(--color-subtle); margin-top: 4px; animation: pulse 1.5s ease-in-out infinite;"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
style="height: 26px; width: 56px; border-radius: var(--radius-md); background: var(--color-subtle); animation: pulse 1.5s ease-in-out infinite;"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if topRecommendations.length > 0}
|
||||
<div data-testid="empfohlen-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);"
|
||||
>
|
||||
Empfohlen · Beste Abwechslung
|
||||
</div>
|
||||
|
||||
{#each topRecommendations as suggestion (suggestion.recipe.id)}
|
||||
{@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}
|
||||
{@render scoreBadge(suggestion.recipe.id, suggestion.scoreDelta ?? 0, suggestion.hasConflict)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
|
||||
disabled={isDisabled}
|
||||
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-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alle Rezepte section -->
|
||||
<div data-testid="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)}
|
||||
{@const score = scoreMap.get(recipe.id)}
|
||||
<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}
|
||||
{#if score}
|
||||
{@render scoreBadge(recipe.id, score.scoreDelta ?? 0, score.hasConflict)}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Wählen"
|
||||
onclick={() => onpick(recipe.id, recipe.name)}
|
||||
disabled={isDisabled}
|
||||
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-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
223
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
223
frontend/src/lib/planner/RecipePicker.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, within } 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 }, scoreDelta: 1.5, hasConflict: false },
|
||||
{ recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, scoreDelta: -1.5, hasConflict: true }
|
||||
];
|
||||
|
||||
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',
|
||||
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 only positive-delta suggestions in Empfohlen', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
// s1 (scoreDelta=1.5) appears in Empfohlen
|
||||
expect(screen.getByText('Lachsfilet')).toBeTruthy();
|
||||
// s2 (scoreDelta=-1.5) is excluded from Empfohlen; not in allRecipes either → absent
|
||||
expect(screen.queryByText('Hähnchen-Curry')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows green badge when hasConflict is false', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
// Lachsfilet: hasConflict = false → green badge
|
||||
const badge = screen.getByTestId('badge-s1');
|
||||
expect(badge.getAttribute('data-type')).toBe('good');
|
||||
});
|
||||
|
||||
it('shows red delta badge in Alle Rezepte when hasConflict is true', () => {
|
||||
// r2 is in allRecipes; scoring it negative via suggestions → red badge in Alle Rezepte
|
||||
const withR2Scored = [
|
||||
...suggestions,
|
||||
{ recipe: { id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: withR2Scored } });
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
const badge = within(alleRezepte).getByTestId('badge-r2');
|
||||
expect(badge.getAttribute('data-type')).toBe('bad');
|
||||
expect(badge.textContent).toContain('-1.5');
|
||||
});
|
||||
|
||||
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 1 is the positive-delta suggestion (s1), rest are allRecipes
|
||||
await userEvent.click(buttons[1]);
|
||||
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();
|
||||
});
|
||||
|
||||
it('shows yellow neutral badge in Alle Rezepte when scoreDelta is zero', () => {
|
||||
// r1 is in allRecipes; scoring it neutral via suggestions → yellow badge in Alle Rezepte
|
||||
const neutralSuggestions = [
|
||||
{ recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: 0.0, hasConflict: false }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: neutralSuggestions } });
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
const badge = within(alleRezepte).getByTestId('badge-r1');
|
||||
expect(badge.getAttribute('data-type')).toBe('neutral');
|
||||
expect(badge.textContent).toContain('Kein Einfluss');
|
||||
});
|
||||
|
||||
it('Empfohlen shows only positive-delta suggestions, capped at 5', () => {
|
||||
const sixImproving = Array.from({ length: 6 }, (_, i) => ({
|
||||
recipe: { id: `imp${i}`, name: `Improving ${i}`, effort: 'easy' as const, cookTimeMin: 20 },
|
||||
scoreDelta: 1.0,
|
||||
hasConflict: false
|
||||
}));
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: sixImproving } });
|
||||
const empfohlen = screen.getByTestId('empfohlen-section');
|
||||
const buttons = empfohlen.querySelectorAll('button');
|
||||
expect(buttons).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('Empfohlen excludes neutral and negative suggestions', () => {
|
||||
const mixed = [
|
||||
{ recipe: { id: 'pos', name: 'Positiv', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 1.0, hasConflict: false },
|
||||
{ recipe: { id: 'neu', name: 'Neutral', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: 0.0, hasConflict: false },
|
||||
{ recipe: { id: 'neg', name: 'Negativ', effort: 'easy' as const, cookTimeMin: 20 }, scoreDelta: -1.0, hasConflict: true }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: mixed } });
|
||||
const empfohlen = screen.getByTestId('empfohlen-section');
|
||||
expect(empfohlen.textContent).toContain('Positiv');
|
||||
expect(empfohlen.textContent).not.toContain('Neutral');
|
||||
expect(empfohlen.textContent).not.toContain('Negativ');
|
||||
});
|
||||
|
||||
it('shows score badge inside Alle Rezepte for a recipe that has a matching suggestion', () => {
|
||||
// r1 is in allRecipes; scoreDelta=-0.3 → not in Empfohlen (needs >0), but scoreMap provides badge
|
||||
const withR1Scored = [
|
||||
...suggestions,
|
||||
{ recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard' as const, cookTimeMin: 150 }, scoreDelta: -0.3, hasConflict: true }
|
||||
];
|
||||
render(RecipePicker, { props: { ...baseProps, suggestions: withR1Scored } });
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
const badge = within(alleRezepte).getByTestId('badge-r1');
|
||||
expect(badge.getAttribute('data-type')).toBe('bad');
|
||||
});
|
||||
|
||||
it('shows no badge in Alle Rezepte for recipes with no suggestion score', () => {
|
||||
// r2 and r3 have no suggestion entry
|
||||
render(RecipePicker, { props: baseProps });
|
||||
const alleRezepte = screen.getByTestId('alle-rezepte-section');
|
||||
expect(within(alleRezepte).queryByTestId('badge-r2')).toBeNull();
|
||||
expect(within(alleRezepte).queryByTestId('badge-r3')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows loading skeleton instead of Empfohlen section when isLoading is true', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, isLoading: true } });
|
||||
expect(screen.getByTestId('suggestions-loading')).toBeTruthy();
|
||||
expect(screen.queryByText(/Empfohlen/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('hides loading skeleton when isLoading is false and suggestions are present', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, isLoading: false } });
|
||||
expect(screen.queryByTestId('suggestions-loading')).toBeNull();
|
||||
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Wird ersetzt banner when replacingRecipe is provided', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta', meta: '20 Min · easy' } } });
|
||||
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
|
||||
expect(screen.getByTestId('replacing-name').textContent).toContain('Pasta');
|
||||
});
|
||||
|
||||
it('hides Wird ersetzt banner when replacingRecipe is not provided', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.queryByText(/Wird ersetzt/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('hides Rezept wählen header when replacingRecipe is set', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta' } } });
|
||||
expect(screen.queryByText(/Rezept wählen/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Rezept wählen header when replacingRecipe is not set', () => {
|
||||
render(RecipePicker, { props: baseProps });
|
||||
expect(screen.getByText(/Rezept wählen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('excludes recipe from Alle Rezepte when excludeRecipeId is set', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 'r2' } });
|
||||
expect(screen.queryByText('Spaghetti Carbonara')).toBeNull();
|
||||
expect(screen.getByText('Beef Bourguignon')).toBeTruthy();
|
||||
expect(screen.getByText('Tomatensuppe')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('excludes recipe from Empfohlen when excludeRecipeId matches a positive-delta suggestion', () => {
|
||||
// s1 (Lachsfilet, scoreDelta=1.5) would normally appear in Empfohlen
|
||||
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 's1' } });
|
||||
expect(screen.queryByText('Lachsfilet')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables Wählen buttons when isDisabled is true', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, isDisabled: true } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
|
||||
});
|
||||
|
||||
it('enables Wählen buttons when isDisabled is false', () => {
|
||||
render(RecipePicker, { props: { ...baseProps, isDisabled: false } });
|
||||
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
|
||||
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
|
||||
});
|
||||
});
|
||||
39
frontend/src/lib/planner/ScoreBreakdownList.svelte
Normal file
39
frontend/src/lib/planner/ScoreBreakdownList.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
interface SubScores {
|
||||
proteinDiversity: number;
|
||||
ingredientOverlap: number;
|
||||
effortBalance: number;
|
||||
}
|
||||
|
||||
let { subScores }: { subScores: SubScores } = $props();
|
||||
</script>
|
||||
|
||||
<ul class="divide-y divide-[var(--color-border)] rounded-[var(--radius-md)] border border-[var(--color-border)]">
|
||||
<li
|
||||
data-testid="sub-protein"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Protein-Vielfalt</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||
{subScores.proteinDiversity}/10
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
data-testid="sub-ingredient"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Zutaten-Überlappung</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||
{subScores.ingredientOverlap}/10
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
data-testid="sub-effort"
|
||||
class="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Aufwandsbalance</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||
{subScores.effortBalance}/10
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
35
frontend/src/lib/planner/ScoreBreakdownList.test.ts
Normal file
35
frontend/src/lib/planner/ScoreBreakdownList.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import ScoreBreakdownList from './ScoreBreakdownList.svelte';
|
||||
|
||||
const subScores = {
|
||||
proteinDiversity: 9,
|
||||
ingredientOverlap: 7,
|
||||
effortBalance: 8
|
||||
};
|
||||
|
||||
describe('ScoreBreakdownList', () => {
|
||||
it('renders protein diversity row', () => {
|
||||
render(ScoreBreakdownList, { props: { subScores } });
|
||||
expect(screen.getByTestId('sub-protein').textContent).toContain('9');
|
||||
});
|
||||
|
||||
it('renders ingredient overlap row', () => {
|
||||
render(ScoreBreakdownList, { props: { subScores } });
|
||||
expect(screen.getByTestId('sub-ingredient').textContent).toContain('7');
|
||||
});
|
||||
|
||||
it('renders effort balance row', () => {
|
||||
render(ScoreBreakdownList, { props: { subScores } });
|
||||
expect(screen.getByTestId('sub-effort').textContent).toContain('8');
|
||||
});
|
||||
|
||||
it('renders all rows with /10 suffix', () => {
|
||||
render(ScoreBreakdownList, { props: { subScores } });
|
||||
const items = screen.getAllByTestId(/^sub-/);
|
||||
expect(items.length).toBe(3);
|
||||
items.forEach((item) => {
|
||||
expect(item.textContent).toContain('/10');
|
||||
});
|
||||
});
|
||||
});
|
||||
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal file
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { formatDayLabel } from './week';
|
||||
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: SlotRecipe | null;
|
||||
}
|
||||
|
||||
interface WeekPlan {
|
||||
id?: string;
|
||||
weekStart?: string;
|
||||
slots?: Slot[];
|
||||
}
|
||||
|
||||
let {
|
||||
selectedDay,
|
||||
weekPlan
|
||||
}: {
|
||||
selectedDay: string;
|
||||
weekPlan: WeekPlan | null;
|
||||
} = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
let slotsWithMeal = $derived(
|
||||
(weekPlan?.slots ?? []).filter((s) => s.recipe && s.slotDate !== selectedDay)
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="context-banner"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--green-light)] bg-[var(--green-tint)] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]">
|
||||
Vorschläge für <strong>{formatDayLabel(selectedDay)}</strong>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="context-detail"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
{expanded ? 'Filter ausblenden ▲' : 'Filter einblenden ▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="context-detail"
|
||||
data-testid="context-detail"
|
||||
aria-hidden={!expanded}
|
||||
{...expanded ? {} : { hidden: true }}
|
||||
>
|
||||
{#if slotsWithMeal.length > 0}
|
||||
<div class="mt-3">
|
||||
<p class="mb-1 font-[var(--font-sans)] text-[11px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Diese Woche bisher
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each slotsWithMeal as slot}
|
||||
<li class="flex gap-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text)]">
|
||||
<span class="text-[var(--color-text-muted)]">{formatDayLabel(slot.slotDate!).split(',')[0]}</span>
|
||||
<span>{slot.recipe?.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte diese Woche geplant
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
48
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal file
48
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import SuggestionContextBanner from './SuggestionContextBanner.svelte';
|
||||
|
||||
const weekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Hard' } }
|
||||
]
|
||||
};
|
||||
|
||||
describe('SuggestionContextBanner', () => {
|
||||
it('renders the selected day label', () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
// Day label should be visible
|
||||
expect(screen.getByTestId('context-banner')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders meals from the current week after expanding', async () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
// Banner starts collapsed — expand it first
|
||||
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
|
||||
await fireEvent.click(toggle);
|
||||
expect(screen.getByText(/Pasta/)).toBeTruthy();
|
||||
expect(screen.getByText(/Curry/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts collapsed and expands on toggle', async () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
const detail = screen.getByTestId('context-detail');
|
||||
// Initially collapsed
|
||||
expect(detail.hasAttribute('hidden')).toBe(true);
|
||||
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
|
||||
await fireEvent.click(toggle);
|
||||
// After toggle: expanded
|
||||
expect(detail.hasAttribute('hidden')).toBe(false);
|
||||
await fireEvent.click(toggle);
|
||||
// After second toggle: collapsed again
|
||||
expect(detail.hasAttribute('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders with no slots gracefully', () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan: { ...weekPlan, slots: [] } } });
|
||||
expect(screen.getByTestId('context-banner')).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();
|
||||
});
|
||||
});
|
||||
62
frontend/src/lib/planner/VarietyScoreCard.svelte
Normal file
62
frontend/src/lib/planner/VarietyScoreCard.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
interface IngredientOverlap {
|
||||
ingredientName?: string;
|
||||
days?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
score,
|
||||
ingredientOverlaps = [],
|
||||
showReviewLink = false
|
||||
}: {
|
||||
score: number;
|
||||
ingredientOverlaps?: IngredientOverlap[];
|
||||
showReviewLink?: boolean;
|
||||
} = $props();
|
||||
|
||||
let percentage = $derived(Math.round((score / 10) * 100));
|
||||
</script>
|
||||
|
||||
<div class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] p-4">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="font-[var(--font-display)] text-[28px] font-[300] text-[var(--color-text)] md:text-[40px]">
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/10</span>
|
||||
<span class="ml-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">Abwechslungs-Score</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
class="mt-2 h-[4px] w-full overflow-hidden rounded-full bg-[var(--yellow-light)]"
|
||||
>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={10}
|
||||
class="h-full rounded-full bg-[var(--yellow)] transition-all"
|
||||
style="width: {percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredient overlap warnings -->
|
||||
{#if ingredientOverlaps.length > 0}
|
||||
<ul class="mt-3 space-y-1">
|
||||
{#each ingredientOverlaps as overlap}
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]">
|
||||
⚠ <span>{overlap.ingredientName}</span> in <span>{overlap.days?.length ?? 0} Mahlzeiten</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if showReviewLink}
|
||||
<a
|
||||
href="/planner/variety"
|
||||
class="mt-3 block font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] hover:underline"
|
||||
>
|
||||
Variety überprüfen →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
79
frontend/src/lib/planner/VarietyScoreCard.test.ts
Normal file
79
frontend/src/lib/planner/VarietyScoreCard.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import VarietyScoreCard from './VarietyScoreCard.svelte';
|
||||
|
||||
const baseProps = {
|
||||
score: 7.5,
|
||||
ingredientOverlaps: [],
|
||||
showReviewLink: false
|
||||
};
|
||||
|
||||
describe('VarietyScoreCard', () => {
|
||||
it('renders the variety score', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
expect(screen.getByText('7.5')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders "/10" denominator', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
expect(screen.getByText('/10')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a progress bar with correct aria attributes', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
const bar = screen.getByRole('progressbar');
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('7.5');
|
||||
expect(bar.getAttribute('aria-valuemin')).toBe('0');
|
||||
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||
});
|
||||
|
||||
it('renders ingredient overlap warnings', () => {
|
||||
render(VarietyScoreCard, {
|
||||
props: {
|
||||
...baseProps,
|
||||
ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }]
|
||||
}
|
||||
});
|
||||
expect(screen.getByText(/Tomate/)).toBeTruthy();
|
||||
expect(screen.getByText(/2 Mahlzeiten/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows review link when showReviewLink is true', () => {
|
||||
render(VarietyScoreCard, { props: { ...baseProps, showReviewLink: true } });
|
||||
const link = screen.getByRole('link', { name: /Variety.*überprüfen|Review variety/i });
|
||||
expect(link).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides review link by default', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
expect(screen.queryByRole('link', { name: /variety/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders with score 0', () => {
|
||||
render(VarietyScoreCard, { props: { ...baseProps, score: 0 } });
|
||||
expect(screen.getByText('0.0')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rounds floating-point scores to one decimal place', () => {
|
||||
render(VarietyScoreCard, { props: { ...baseProps, score: 6.199999999999999 } });
|
||||
expect(screen.getByText('6.2')).toBeTruthy();
|
||||
expect(screen.queryByText('6.199999999999999')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders multiple ingredient overlap warnings', () => {
|
||||
render(VarietyScoreCard, {
|
||||
props: {
|
||||
...baseProps,
|
||||
ingredientOverlaps: [
|
||||
{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] },
|
||||
{ ingredientName: 'Zwiebel', days: ['2026-03-30', '2026-04-01', '2026-04-02'] },
|
||||
{ ingredientName: 'Knoblauch', days: ['2026-03-31', '2026-04-01'] }
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(screen.getByText(/Tomate/)).toBeTruthy();
|
||||
expect(screen.getByText(/Zwiebel/)).toBeTruthy();
|
||||
expect(screen.getByText(/Knoblauch/)).toBeTruthy();
|
||||
expect(screen.getByText(/3 Mahlzeiten/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/planner/VarietyScoreHero.svelte
Normal file
56
frontend/src/lib/planner/VarietyScoreHero.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
score
|
||||
}: {
|
||||
score: number;
|
||||
} = $props();
|
||||
|
||||
let percentage = $derived(Math.round((score / 10) * 100));
|
||||
|
||||
let description = $derived(
|
||||
score >= 9
|
||||
? { label: 'Ausgezeichnet', colorClass: 'text-[var(--green-dark)]' }
|
||||
: score >= 7
|
||||
? { label: 'Gut', colorClass: 'text-[var(--color-text)]' }
|
||||
: score >= 4
|
||||
? { label: 'Verbesserbar', colorClass: 'text-[var(--yellow-text)]' }
|
||||
: { label: 'Unzureichend', colorClass: 'text-[var(--color-error)]' }
|
||||
);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Score number + out of 10 -->
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span
|
||||
data-testid="score-value"
|
||||
class="font-[var(--font-display)] text-[56px] font-[300] leading-none text-[var(--color-text)] lg:text-[72px]"
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
<span
|
||||
data-testid="score-label"
|
||||
class="font-[var(--font-sans)] text-[16px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
/ 10
|
||||
</span>
|
||||
<span
|
||||
data-testid="score-description"
|
||||
class="ml-1 font-[var(--font-sans)] text-[14px] font-medium {description.colorClass}"
|
||||
>
|
||||
{description.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mt-3 h-[6px] w-[120px] overflow-hidden rounded-full bg-[var(--color-border)] lg:w-[200px]">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={10}
|
||||
aria-label="Abwechslungs-Score"
|
||||
class="h-full rounded-full bg-[var(--yellow)] transition-all"
|
||||
style="width: {percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
74
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal file
74
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import VarietyScoreHero from './VarietyScoreHero.svelte';
|
||||
|
||||
describe('VarietyScoreHero', () => {
|
||||
it('renders the score number', () => {
|
||||
render(VarietyScoreHero, { props: { score: 8.2 } });
|
||||
expect(screen.getByTestId('score-value').textContent).toContain('8.2');
|
||||
});
|
||||
|
||||
it('renders "out of 10" label', () => {
|
||||
render(VarietyScoreHero, { props: { score: 8.2 } });
|
||||
expect(screen.getByTestId('score-label').textContent).toContain('10');
|
||||
});
|
||||
|
||||
it('renders a progressbar with correct aria attributes', () => {
|
||||
render(VarietyScoreHero, { props: { score: 8.2 } });
|
||||
const bar = screen.getByRole('progressbar');
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('8.2');
|
||||
expect(bar.getAttribute('aria-valuemin')).toBe('0');
|
||||
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||
});
|
||||
|
||||
it('shows "Excellent variety" description for score >= 9', () => {
|
||||
render(VarietyScoreHero, { props: { score: 9.5 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet');
|
||||
});
|
||||
|
||||
it('shows "Good variety" description for score 7-8.9', () => {
|
||||
render(VarietyScoreHero, { props: { score: 7.5 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Gut');
|
||||
});
|
||||
|
||||
it('shows "Getting there" description for score 4-6.9', () => {
|
||||
render(VarietyScoreHero, { props: { score: 5.0 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar');
|
||||
});
|
||||
|
||||
it('shows "Needs improvement" description for score < 4', () => {
|
||||
render(VarietyScoreHero, { props: { score: 2.1 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend');
|
||||
});
|
||||
|
||||
it('shows "Unzureichend" for score = 0 (boundary)', () => {
|
||||
render(VarietyScoreHero, { props: { score: 0 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend');
|
||||
});
|
||||
|
||||
it('renders score 0 in score-value for score = 0', () => {
|
||||
render(VarietyScoreHero, { props: { score: 0 } });
|
||||
expect(screen.getByTestId('score-value').textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('renders 0-width progress bar for score = 0', () => {
|
||||
render(VarietyScoreHero, { props: { score: 0 } });
|
||||
const bar = screen.getByRole('progressbar');
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('0');
|
||||
});
|
||||
|
||||
it('shows "Ausgezeichnet" for score = 10 (boundary)', () => {
|
||||
render(VarietyScoreHero, { props: { score: 10 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet');
|
||||
});
|
||||
|
||||
it('shows "Verbesserbar" for score = 4 (boundary)', () => {
|
||||
render(VarietyScoreHero, { props: { score: 4 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar');
|
||||
});
|
||||
|
||||
it('shows "Gut" for score = 7 (boundary)', () => {
|
||||
render(VarietyScoreHero, { props: { score: 7 } });
|
||||
expect(screen.getByTestId('score-description').textContent).toContain('Gut');
|
||||
});
|
||||
});
|
||||
22
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal file
22
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
interface Warning {
|
||||
title: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
let { warnings }: { warnings: Warning[] } = $props();
|
||||
</script>
|
||||
|
||||
{#each warnings as warning}
|
||||
<div
|
||||
data-testid="warning-card"
|
||||
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] px-4 py-3"
|
||||
>
|
||||
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
|
||||
{warning.title}
|
||||
</p>
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{warning.explanation}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
32
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal file
32
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import VarietyWarningCards from './VarietyWarningCards.svelte';
|
||||
|
||||
const warnings = [
|
||||
{ title: 'Chicken zweimal diese Woche', explanation: 'Mo, Mi — erwäge einen Tausch.' },
|
||||
{ title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' }
|
||||
];
|
||||
|
||||
describe('VarietyWarningCards', () => {
|
||||
it('renders one card per warning', () => {
|
||||
render(VarietyWarningCards, { props: { warnings } });
|
||||
const cards = screen.getAllByTestId('warning-card');
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders warning titles', () => {
|
||||
render(VarietyWarningCards, { props: { warnings } });
|
||||
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
|
||||
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders warning explanations', () => {
|
||||
render(VarietyWarningCards, { props: { warnings } });
|
||||
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing when warnings is empty', () => {
|
||||
render(VarietyWarningCards, { props: { warnings: [] } });
|
||||
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/planner/WeekStrip.svelte
Normal file
70
frontend/src/lib/planner/WeekStrip.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { weekDays, formatDayAbbr } from './week';
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: { id?: string; name?: string } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
weekStart,
|
||||
slots = [],
|
||||
selectedDay,
|
||||
today,
|
||||
onselectDay
|
||||
}: {
|
||||
weekStart: string;
|
||||
slots?: Slot[];
|
||||
selectedDay: string;
|
||||
today: string;
|
||||
onselectDay?: (day: string) => void;
|
||||
} = $props();
|
||||
|
||||
let days = $derived(weekDays(weekStart));
|
||||
let slotMap = $derived(
|
||||
Object.fromEntries(slots.map((s) => [s.slotDate!, s]))
|
||||
);
|
||||
|
||||
function selectDay(day: string) {
|
||||
onselectDay?.(day);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-7 gap-[2px] md:gap-[6px]">
|
||||
{#each days as day}
|
||||
{@const isSelected = day === selectedDay}
|
||||
{@const isTodayDay = day === today}
|
||||
{@const hasMeal = !!slotMap[day]?.recipe}
|
||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
||||
{@const abbr = formatDayAbbr(day, 'narrow')}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="day-chip-{day}"
|
||||
data-selected={isSelected}
|
||||
data-today={isTodayDay}
|
||||
onclick={() => selectDay(day)}
|
||||
class="flex flex-col items-center rounded-[10px] px-1 py-2 transition-colors
|
||||
{isTodayDay ? 'border border-[var(--yellow-light)] bg-[var(--yellow-tint)]' : ''}
|
||||
{isSelected && !isTodayDay ? 'border border-[var(--green-light)] bg-[var(--green-tint)]' : ''}
|
||||
{!isTodayDay && !isSelected ? 'border border-transparent' : ''}"
|
||||
>
|
||||
<span class="font-[var(--font-sans)] text-[7px] uppercase tracking-wide text-[var(--color-text-muted)] md:text-[10px]">
|
||||
{abbr}
|
||||
</span>
|
||||
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--color-text)] md:text-[14px]">
|
||||
{dateNum}
|
||||
</span>
|
||||
<!-- Dot indicator -->
|
||||
<span
|
||||
data-testid="dot-{day}"
|
||||
data-has-meal={hasMeal}
|
||||
class="mt-1 h-[3px] w-[3px] rounded-full
|
||||
{hasMeal ? 'bg-[var(--green)]' : ''}
|
||||
{!hasMeal && isTodayDay ? 'bg-[var(--yellow-text)]' : ''}
|
||||
{!hasMeal && !isTodayDay ? 'bg-[var(--color-border)]' : ''}"
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
66
frontend/src/lib/planner/WeekStrip.test.ts
Normal file
66
frontend/src/lib/planner/WeekStrip.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import WeekStrip from './WeekStrip.svelte';
|
||||
|
||||
const weekStart = '2026-03-30'; // Monday
|
||||
|
||||
const slots = [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium' } }
|
||||
];
|
||||
|
||||
describe('WeekStrip', () => {
|
||||
it('renders 7 day chips', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const chips = screen.getAllByRole('button');
|
||||
expect(chips).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('marks today chip with data-today attribute', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const todayChip = screen.getByTestId('day-chip-2026-04-03');
|
||||
expect(todayChip.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('marks selected chip with data-selected attribute', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const selectedChip = screen.getByTestId('day-chip-2026-03-30');
|
||||
expect(selectedChip.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows meal indicator dot for days with a meal', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const dot = screen.getByTestId('dot-2026-03-30');
|
||||
expect(dot.getAttribute('data-has-meal')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows empty dot for days without a meal', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
// 2026-04-01 has no meal
|
||||
const dot = screen.getByTestId('dot-2026-04-01');
|
||||
expect(dot.getAttribute('data-has-meal')).toBe('false');
|
||||
});
|
||||
|
||||
it('when today equals selected day, both data-today and data-selected are true', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-04-03', today: '2026-04-03' } });
|
||||
const chip = screen.getByTestId('day-chip-2026-04-03');
|
||||
expect(chip.getAttribute('data-today')).toBe('true');
|
||||
expect(chip.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('calls onselectDay callback when chip is clicked', async () => {
|
||||
let emitted: string | null = null;
|
||||
render(WeekStrip, {
|
||||
props: {
|
||||
weekStart,
|
||||
slots,
|
||||
selectedDay: '2026-03-30',
|
||||
today: '2026-04-03',
|
||||
onselectDay: (day: string) => { emitted = day; }
|
||||
}
|
||||
});
|
||||
const chip = screen.getByTestId('day-chip-2026-03-31');
|
||||
chip.click();
|
||||
expect(emitted).toBe('2026-03-31');
|
||||
});
|
||||
});
|
||||
12
frontend/src/lib/planner/types.ts
Normal file
12
frontend/src/lib/planner/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
recipe: Recipe;
|
||||
scoreDelta: number;
|
||||
hasConflict: boolean;
|
||||
}
|
||||
123
frontend/src/lib/planner/variety.test.ts
Normal file
123
frontend/src/lib/planner/variety.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeSubScores, computeWarnings } from './variety';
|
||||
|
||||
describe('computeSubScores', () => {
|
||||
it('returns proteinDiversity=10 when no protein repeats', () => {
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 2, hard: 1 });
|
||||
expect(result.proteinDiversity).toBe(10);
|
||||
});
|
||||
|
||||
it('reduces proteinDiversity by 2 per protein repeat', () => {
|
||||
const tagRepeats = [
|
||||
{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] },
|
||||
{ tagType: 'protein', tagName: 'Beef', days: ['WED', 'THU'] }
|
||||
];
|
||||
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||
// 2 protein repeat entries → 10 - 2*2 = 6
|
||||
expect(result.proteinDiversity).toBe(6);
|
||||
});
|
||||
|
||||
it('clamps proteinDiversity to minimum 0', () => {
|
||||
const tagRepeats = Array.from({ length: 6 }, (_, i) => ({
|
||||
tagType: 'protein', tagName: `P${i}`, days: ['MON', 'TUE']
|
||||
}));
|
||||
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||
expect(result.proteinDiversity).toBe(0);
|
||||
});
|
||||
|
||||
it('returns ingredientOverlap=10 when no overlaps', () => {
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||
expect(result.ingredientOverlap).toBe(10);
|
||||
});
|
||||
|
||||
it('reduces ingredientOverlap by 1.5 per overlap (rounded)', () => {
|
||||
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'TUE'] }];
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 });
|
||||
// 1 overlap → 10 - 1*1.5 = 8.5 → round = 9 (Math.round rounds .5 up)
|
||||
expect(result.ingredientOverlap).toBe(9);
|
||||
});
|
||||
|
||||
it('clamps ingredientOverlap to minimum 0', () => {
|
||||
const ingredientOverlaps = Array.from({ length: 8 }, (_, i) => ({
|
||||
ingredientName: `Ing${i}`, days: ['MON', 'TUE']
|
||||
}));
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 });
|
||||
expect(result.ingredientOverlap).toBe(0);
|
||||
});
|
||||
|
||||
it('returns effortBalance=10 when no meals (total=0)', () => {
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||
expect(result.effortBalance).toBe(10);
|
||||
});
|
||||
|
||||
it('returns effortBalance=10 when easy and hard are equal', () => {
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 3, medium: 0, hard: 3 });
|
||||
// |3-3| = 0 → 10 - 0 = 10
|
||||
expect(result.effortBalance).toBe(10);
|
||||
});
|
||||
|
||||
it('reduces effortBalance by 1.5 per unit of easy-hard difference', () => {
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 0, hard: 0 });
|
||||
// |4-0| = 4 → 10 - 4*1.5 = 4 → round(4) = 4
|
||||
expect(result.effortBalance).toBe(4);
|
||||
});
|
||||
|
||||
it('clamps effortBalance to minimum 0', () => {
|
||||
const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 10, medium: 0, hard: 0 });
|
||||
// |10-0| = 10 → 10 - 10*1.5 = -5 → clamp to 0
|
||||
expect(result.effortBalance).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores non-protein tag repeats for proteinDiversity', () => {
|
||||
const tagRepeats = [{ tagType: 'category', tagName: 'Pasta', days: ['MON', 'TUE'] }];
|
||||
const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 });
|
||||
expect(result.proteinDiversity).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeWarnings', () => {
|
||||
it('returns empty array when no repeats or overlaps', () => {
|
||||
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: [] });
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('generates warning for protein appearing on 2+ days', () => {
|
||||
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }];
|
||||
const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toContain('Chicken');
|
||||
});
|
||||
|
||||
it('does not generate warning for protein appearing on only 1 day', () => {
|
||||
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON'] }];
|
||||
const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] });
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('generates warning for ingredient overlap on 2+ days', () => {
|
||||
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }];
|
||||
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toContain('Rice');
|
||||
});
|
||||
|
||||
it('does not generate warning for ingredient appearing on only 1 day', () => {
|
||||
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON'] }];
|
||||
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] });
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('generates warning for each duplicate recipe in plan', () => {
|
||||
const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: ['Pasta Bolognese', 'Risotto'] });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].title).toContain('Pasta Bolognese');
|
||||
expect(result[1].title).toContain('Risotto');
|
||||
});
|
||||
|
||||
it('combines all warning types', () => {
|
||||
const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }];
|
||||
const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }];
|
||||
const result = computeWarnings({ tagRepeats, ingredientOverlaps, duplicatesInPlan: ['Pasta'] });
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
88
frontend/src/lib/planner/variety.ts
Normal file
88
frontend/src/lib/planner/variety.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
interface TagRepeat {
|
||||
tagType?: string;
|
||||
tagName?: string;
|
||||
days?: string[];
|
||||
}
|
||||
|
||||
interface IngredientOverlap {
|
||||
ingredientName?: string;
|
||||
days?: string[];
|
||||
}
|
||||
|
||||
interface SubScoreInput {
|
||||
tagRepeats: TagRepeat[];
|
||||
ingredientOverlaps: IngredientOverlap[];
|
||||
easy: number;
|
||||
medium: number;
|
||||
hard: number;
|
||||
}
|
||||
|
||||
export interface SubScores {
|
||||
proteinDiversity: number;
|
||||
ingredientOverlap: number;
|
||||
effortBalance: number;
|
||||
}
|
||||
|
||||
export function computeSubScores(input: SubScoreInput): SubScores {
|
||||
const { tagRepeats, ingredientOverlaps, easy, medium, hard } = input;
|
||||
|
||||
const proteinRepeats = tagRepeats.filter((t) => t.tagType === 'protein').length;
|
||||
const ingredientOverlapCount = ingredientOverlaps.length;
|
||||
const total = easy + medium + hard;
|
||||
|
||||
const effortBalance =
|
||||
total === 0
|
||||
? 10
|
||||
: Math.min(10, Math.round(Math.max(0, 10 - Math.abs(easy - hard) * 1.5)));
|
||||
|
||||
return {
|
||||
proteinDiversity: Math.max(0, Math.round(10 - proteinRepeats * 2)),
|
||||
ingredientOverlap: Math.max(0, Math.round(10 - ingredientOverlapCount * 1.5)),
|
||||
effortBalance
|
||||
};
|
||||
}
|
||||
|
||||
interface WarningInput {
|
||||
tagRepeats: TagRepeat[];
|
||||
ingredientOverlaps: IngredientOverlap[];
|
||||
duplicatesInPlan: string[];
|
||||
}
|
||||
|
||||
export interface Warning {
|
||||
title: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export function computeWarnings(input: WarningInput): Warning[] {
|
||||
const { tagRepeats, ingredientOverlaps, duplicatesInPlan } = input;
|
||||
const result: Warning[] = [];
|
||||
|
||||
for (const repeat of tagRepeats) {
|
||||
if ((repeat.days?.length ?? 0) > 1) {
|
||||
const days = (repeat.days ?? []).join(', ');
|
||||
result.push({
|
||||
title: `${repeat.tagName} mehrfach diese Woche`,
|
||||
explanation: `${days} — erwäge einen Tausch für mehr Protein-Abwechslung.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const overlap of ingredientOverlaps) {
|
||||
if ((overlap.days?.length ?? 0) > 1) {
|
||||
const days = (overlap.days ?? []).join(', ');
|
||||
result.push({
|
||||
title: `${overlap.ingredientName} in mehreren Gerichten`,
|
||||
explanation: `${days} — sorge für Zutaten-Abwechslung.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of duplicatesInPlan) {
|
||||
result.push({
|
||||
title: `${name} doppelt geplant`,
|
||||
explanation: 'Dasselbe Rezept erscheint mehrfach — tausche eines aus.'
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
196
frontend/src/lib/planner/week.test.ts
Normal file
196
frontend/src/lib/planner/week.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getWeekStart,
|
||||
prevWeek,
|
||||
nextWeek,
|
||||
weekDays,
|
||||
isToday,
|
||||
formatWeekRange,
|
||||
formatDayLabel,
|
||||
sortEasiestFirst
|
||||
} from './week';
|
||||
|
||||
describe('getWeekStart', () => {
|
||||
it('returns Monday for a Monday input', () => {
|
||||
// 2026-03-30 is a Monday
|
||||
const d = new Date('2026-03-30T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Wednesday input', () => {
|
||||
// 2026-04-01 is a Wednesday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-01T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Sunday input (edge case — goes back 6 days)', () => {
|
||||
// 2026-04-05 is a Sunday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-05T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Saturday input', () => {
|
||||
// 2026-04-04 is a Saturday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-04T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('handles year boundary correctly (Dec 28 2025 → week starts Dec 22 2025)', () => {
|
||||
const d = new Date('2025-12-28T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2025-12-22');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prevWeek', () => {
|
||||
it('returns the Monday 7 days before', () => {
|
||||
expect(prevWeek('2026-03-30')).toBe('2026-03-23');
|
||||
});
|
||||
|
||||
it('handles month boundary', () => {
|
||||
expect(prevWeek('2026-04-06')).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('handles year boundary', () => {
|
||||
expect(prevWeek('2026-01-05')).toBe('2025-12-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextWeek', () => {
|
||||
it('returns the Monday 7 days after', () => {
|
||||
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
|
||||
});
|
||||
|
||||
it('handles month boundary', () => {
|
||||
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
|
||||
});
|
||||
|
||||
it('handles year boundary', () => {
|
||||
expect(nextWeek('2025-12-29')).toBe('2026-01-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('returns exactly 7 dates', () => {
|
||||
expect(weekDays('2026-03-30')).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('starts on the given weekStart', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[0]).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('ends 6 days after weekStart', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[6]).toBe('2026-04-05');
|
||||
});
|
||||
|
||||
it('has consecutive daily dates', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
for (let i = 1; i < 7; i++) {
|
||||
const prev = new Date(days[i - 1] + 'T00:00:00Z');
|
||||
const curr = new Date(days[i] + 'T00:00:00Z');
|
||||
expect(curr.getTime() - prev.getTime()).toBe(86400000);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles month boundary correctly', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[1]).toBe('2026-03-31');
|
||||
expect(days[2]).toBe('2026-04-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToday', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns true for today (UTC)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-03')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for yesterday', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-02')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for tomorrow', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-04')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWeekRange', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
expect(formatWeekRange('2026-03-30')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('contains both start and end month info', () => {
|
||||
const range = formatWeekRange('2026-03-30');
|
||||
// Start is March 30, end is April 5 — range should span both months
|
||||
expect(range).toContain('–');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDayLabel', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
expect(formatDayLabel('2026-03-30')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('contains a comma separator', () => {
|
||||
expect(formatDayLabel('2026-03-30')).toContain(',');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortEasiestFirst', () => {
|
||||
it('sorts easy before medium before hard', () => {
|
||||
const recipes = [
|
||||
{ id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
|
||||
{ id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 },
|
||||
{ id: '3', name: 'Medium', effort: 'medium', cookTimeMin: 10 }
|
||||
];
|
||||
const sorted = sortEasiestFirst(recipes);
|
||||
expect(sorted.map((r) => r.effort)).toEqual(['easy', 'medium', 'hard']);
|
||||
});
|
||||
|
||||
it('sorts by cookTimeMin ascending within same effort', () => {
|
||||
const recipes = [
|
||||
{ id: '1', name: 'Slow Easy', effort: 'easy', cookTimeMin: 60 },
|
||||
{ id: '2', name: 'Fast Easy', effort: 'easy', cookTimeMin: 15 }
|
||||
];
|
||||
const sorted = sortEasiestFirst(recipes);
|
||||
expect(sorted[0].name).toBe('Fast Easy');
|
||||
});
|
||||
|
||||
it('treats missing effort as after hard', () => {
|
||||
const recipes = [
|
||||
{ id: '1', name: 'No effort', effort: undefined, cookTimeMin: 5 },
|
||||
{ id: '2', name: 'Hard', effort: 'hard', cookTimeMin: 5 }
|
||||
];
|
||||
const sorted = sortEasiestFirst(recipes);
|
||||
expect(sorted[0].effort).toBe('hard');
|
||||
});
|
||||
|
||||
it('treats missing cookTimeMin as after defined values', () => {
|
||||
const recipes = [
|
||||
{ id: '1', name: 'No time', effort: 'easy', cookTimeMin: undefined },
|
||||
{ id: '2', name: 'Has time', effort: 'easy', cookTimeMin: 30 }
|
||||
];
|
||||
const sorted = sortEasiestFirst(recipes);
|
||||
expect(sorted[0].name).toBe('Has time');
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const recipes = [
|
||||
{ id: '1', name: 'Hard', effort: 'hard', cookTimeMin: 10 },
|
||||
{ id: '2', name: 'Easy', effort: 'easy', cookTimeMin: 10 }
|
||||
];
|
||||
const original = [...recipes];
|
||||
sortEasiestFirst(recipes);
|
||||
expect(recipes[0].effort).toBe(original[0].effort);
|
||||
});
|
||||
});
|
||||
107
frontend/src/lib/planner/week.ts
Normal file
107
frontend/src/lib/planner/week.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Returns the ISO Monday (YYYY-MM-DD) for the week containing `date`.
|
||||
*/
|
||||
export function getWeekStart(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const day = d.getUTCDay(); // 0=Sun, 1=Mon, …
|
||||
const diff = day === 0 ? -6 : 1 - day; // shift to Monday
|
||||
d.setUTCDate(d.getUTCDate() + diff);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Monday of the previous week relative to `weekStart`.
|
||||
*/
|
||||
export function prevWeek(weekStart: string): string {
|
||||
const d = new Date(weekStart + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() - 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Monday of the next week relative to `weekStart`.
|
||||
*/
|
||||
export function nextWeek(weekStart: string): string {
|
||||
const d = new Date(weekStart + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string (YYYY-MM-DD) as a localized day abbreviation.
|
||||
*/
|
||||
export function formatDayAbbr(dateStr: string, length: 'narrow' | 'short' = 'narrow'): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
return d.toLocaleDateString('de-DE', { weekday: length, timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of 7 date strings for the week starting on `weekStart`.
|
||||
*/
|
||||
export function weekDays(weekStart: string): string[] {
|
||||
const days: string[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(weekStart + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
days.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string as "Mo, 30.03." style label.
|
||||
*/
|
||||
export function formatDayLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
const day = d.toLocaleDateString('de-DE', { weekday: 'short', timeZone: 'UTC' });
|
||||
const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: 'UTC' });
|
||||
return `${day}, ${date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string as "30. März" style label.
|
||||
*/
|
||||
export function formatDayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if dateStr is today (UTC date).
|
||||
* Uses UTC consistently with all other date functions in this module.
|
||||
*/
|
||||
export function isToday(dateStr: string): boolean {
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
return dateStr === todayStr;
|
||||
}
|
||||
|
||||
const EFFORT_ORDER: Record<string, number> = { easy: 0, medium: 1, hard: 2 };
|
||||
|
||||
/**
|
||||
* Returns a new array of recipes sorted easiest first (effort ASC, cookTimeMin ASC).
|
||||
* Used for the J4 mid-week swap context — different from variety-first sorting in J2.
|
||||
*/
|
||||
export function sortEasiestFirst<T extends { effort?: string | null; cookTimeMin?: number | null }>(
|
||||
recipes: T[]
|
||||
): T[] {
|
||||
return [...recipes].sort((a, b) => {
|
||||
const ea = a.effort != null ? (EFFORT_ORDER[a.effort] ?? 99) : 99;
|
||||
const eb = b.effort != null ? (EFFORT_ORDER[b.effort] ?? 99) : 99;
|
||||
if (ea !== eb) return ea - eb;
|
||||
const ta = a.cookTimeMin ?? Infinity;
|
||||
const tb = b.cookTimeMin ?? Infinity;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a week range: "30. Mär – 5. Apr 2026".
|
||||
*/
|
||||
export function formatWeekRange(weekStart: string): string {
|
||||
const start = new Date(weekStart + 'T00:00:00Z');
|
||||
const end = new Date(weekStart + 'T00:00:00Z');
|
||||
end.setUTCDate(end.getUTCDate() + 6);
|
||||
const startStr = start.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
const endStr = end.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
20
frontend/src/lib/recipes/FilterChipRow.svelte
Normal file
20
frontend/src/lib/recipes/FilterChipRow.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { activeFilter, onFilter }: { activeFilter: string; onFilter: (filter: string) => void } = $props();
|
||||
|
||||
const chips = ['Alle', 'Leicht', 'Mittel', 'Schwer'];
|
||||
</script>
|
||||
|
||||
<div class="flex gap-[8px] overflow-x-auto px-[16px] py-[12px] scrollbar-none">
|
||||
{#each chips as label (label)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeFilter === label}
|
||||
onclick={() => onFilter(label)}
|
||||
class="font-sans text-[13px] font-medium tracking-[0.04em] px-[14px] py-[5px] rounded-[12px] border cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)] {activeFilter === label
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)] border-[var(--green-light)]'
|
||||
: 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--color-border)]'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
51
frontend/src/lib/recipes/FilterChipRow.test.ts
Normal file
51
frontend/src/lib/recipes/FilterChipRow.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import FilterChipRow from './FilterChipRow.svelte';
|
||||
|
||||
describe('FilterChipRow', () => {
|
||||
it('renders all effort filter chips', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Mittel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Schwer' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks active chip with aria-pressed=true', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('marks inactive chips with aria-pressed=false', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Mittel' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Schwer' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('calls onFilter with the chip label when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leicht' }));
|
||||
expect(onFilter).toHaveBeenCalledWith('Leicht');
|
||||
});
|
||||
|
||||
it('calls onFilter with Alle when reset chip clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Alle' }));
|
||||
expect(onFilter).toHaveBeenCalledWith('Alle');
|
||||
});
|
||||
|
||||
it('active chip has green-tint styling', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Mittel', onFilter: vi.fn() } });
|
||||
const btn = screen.getByRole('button', { name: 'Mittel' });
|
||||
expect(btn.className).toContain('bg-[var(--green-tint)]');
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Ingredient } from './types';
|
||||
|
||||
let { ingredients }: { ingredients: Ingredient[] } = $props();
|
||||
|
||||
const sortedIngredients = $derived(
|
||||
[...ingredients].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||
>
|
||||
Zutaten
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
{#each sortedIngredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
|
||||
<li class="flex items-baseline gap-[12px] py-[8px] border-b border-[var(--color-border)] last:border-b-0">
|
||||
{#if ingredient.quantity != null}
|
||||
<span class="text-[13px] font-medium text-[var(--color-text)] w-[80px] shrink-0">
|
||||
{ingredient.quantity}{ingredient.unit != null ? ` ${ingredient.unit}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-[14px] text-[var(--color-text)]">{ingredient.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import IngredientList from './IngredientList.svelte';
|
||||
|
||||
const mockIngredients = [
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' },
|
||||
{ ingredientId: 'i3', name: 'Salz', quantity: undefined, unit: undefined }
|
||||
];
|
||||
|
||||
describe('IngredientList', () => {
|
||||
it('renders the section heading', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a row for each ingredient', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Salz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quantity and unit when present', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText('200 g')).toBeInTheDocument();
|
||||
expect(screen.getByText('400 g')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no quantity when not present', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.queryByText('undefined')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has no remove buttons (read-only)', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when ingredients array is empty', () => {
|
||||
render(IngredientList, { props: { ingredients: [] } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ingredients sorted by sortOrder', () => {
|
||||
const unsorted = [
|
||||
{ ingredientId: 'i3', name: 'Oregano', quantity: 1, unit: 'TL', sortOrder: 3 },
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g', sortOrder: 1 },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g', sortOrder: 2 }
|
||||
];
|
||||
render(IngredientList, { props: { ingredients: unsorted } });
|
||||
const spans = document.querySelectorAll('li span:last-child');
|
||||
expect(spans[0].textContent).toBe('Spaghetti');
|
||||
expect(spans[1].textContent).toBe('Hackfleisch');
|
||||
expect(spans[2].textContent).toBe('Oregano');
|
||||
});
|
||||
});
|
||||
77
frontend/src/lib/recipes/RecipeCard.svelte
Normal file
77
frontend/src/lib/recipes/RecipeCard.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import type { RecipeSummary } from './types';
|
||||
|
||||
let { recipe, compact = false, onplan }: {
|
||||
recipe: RecipeSummary;
|
||||
compact?: boolean;
|
||||
onplan?: ((recipeId: string, recipeName: string) => void);
|
||||
} = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
|
||||
recipe.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]">
|
||||
<a href="/recipes/{recipe.id}" class="block">
|
||||
<div
|
||||
data-testid="image-area"
|
||||
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
||||
>
|
||||
{#if recipe.heroImageUrl}
|
||||
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
data-testid="image-placeholder"
|
||||
class="w-full h-full bg-[var(--color-border)] flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class="text-[var(--color-text-muted)] opacity-50"
|
||||
>
|
||||
<!-- plate -->
|
||||
<circle cx="12" cy="13" r="6" />
|
||||
<path d="M12 7V5" />
|
||||
<!-- fork tines -->
|
||||
<path d="M8 3v3c0 1.1.9 2 2 2h4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-1.5">
|
||||
<p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p>
|
||||
{#if metadata}
|
||||
<p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{#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>
|
||||
86
frontend/src/lib/recipes/RecipeCard.test.ts
Normal file
86
frontend/src/lib/recipes/RecipeCard.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import RecipeCard from './RecipeCard.svelte';
|
||||
|
||||
const mockRecipe = {
|
||||
id: 'recipe-1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined
|
||||
};
|
||||
|
||||
describe('RecipeCard', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cook time when present', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText(/30/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders effort when present', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText(/easy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows placeholder when no heroImageUrl', () => {
|
||||
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image when heroImageUrl is provided', () => {
|
||||
render(RecipeCard, {
|
||||
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
|
||||
});
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
|
||||
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
it('has a link to the recipe detail page', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
const link = screen.getByRole('link', { name: /Spaghetti Bolognese/i });
|
||||
expect(link).toHaveAttribute('href', '/recipes/recipe-1');
|
||||
});
|
||||
|
||||
it('shows Jetzt kochen link when onplan provided', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, onplan: vi.fn() } });
|
||||
const cookLink = screen.getByRole('link', { name: /Jetzt kochen/i });
|
||||
expect(cookLink).toHaveAttribute('href', '/cook/recipe-1');
|
||||
});
|
||||
|
||||
it('does not show Jetzt kochen when onplan not provided', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Zur Woche + button when onplan provided', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, onplan: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: /Zur Woche/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onplan with recipeId and name when Zur Woche + clicked', async () => {
|
||||
const onplan = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, onplan } });
|
||||
await user.click(screen.getByRole('button', { name: /Zur Woche/i }));
|
||||
expect(onplan).toHaveBeenCalledWith('recipe-1', 'Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
it('applies compact image height when compact prop is true', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, compact: true } });
|
||||
const imageArea = document.querySelector('[data-testid="image-area"]');
|
||||
expect(imageArea?.className).toContain('h-[64px]');
|
||||
});
|
||||
|
||||
it('applies full image height when compact prop is false', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, compact: false } });
|
||||
const imageArea = document.querySelector('[data-testid="image-area"]');
|
||||
expect(imageArea?.className).toContain('h-[100px]');
|
||||
});
|
||||
});
|
||||
282
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
282
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
type Category = { id: string; name: string; tagType?: string };
|
||||
|
||||
type EditRecipe = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
ingredients: { name: string; quantity: number; unit: string }[];
|
||||
steps: { instruction: string }[];
|
||||
tagIds: string[];
|
||||
} | null;
|
||||
|
||||
const { recipe, categories, action }: {
|
||||
recipe: EditRecipe;
|
||||
categories: Category[];
|
||||
action: string;
|
||||
} = $props();
|
||||
|
||||
const effortOptions = [
|
||||
{ label: 'Leicht', value: 'Easy' },
|
||||
{ label: 'Mittel', value: 'Medium' },
|
||||
{ label: 'Schwer', value: 'Hard' }
|
||||
];
|
||||
|
||||
const initial = (() => $state.snapshot(recipe))();
|
||||
|
||||
let name = $state(initial?.name ?? '');
|
||||
let serves = $state<number | ''>(initial?.serves ?? '');
|
||||
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
|
||||
let effort = $state(initial?.effort ?? '');
|
||||
let selectedTagIds = $state<string[]>(initial?.tagIds ? [...initial.tagIds] : []);
|
||||
let ingredients = $state(
|
||||
initial?.ingredients.map((ing) => ({
|
||||
name: ing.name,
|
||||
quantity: ing.quantity as number | '',
|
||||
unit: ing.unit
|
||||
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
|
||||
);
|
||||
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||
</script>
|
||||
|
||||
<form method="POST" {action} use:enhance>
|
||||
<!-- Error banner -->
|
||||
{#if $page.form?.error}
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-[20px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
|
||||
>
|
||||
{$page.form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div class="md:flex md:gap-[32px]">
|
||||
<!-- Left column: main form fields -->
|
||||
<div class="md:flex-1">
|
||||
<!-- Basic info -->
|
||||
<div class="mb-[24px]">
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="name"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="serves"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Portionen
|
||||
</label>
|
||||
<input
|
||||
id="serves"
|
||||
name="serves"
|
||||
type="number"
|
||||
bind:value={serves}
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="cookTimeMin"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Kochzeit
|
||||
</label>
|
||||
<input
|
||||
id="cookTimeMin"
|
||||
name="cookTimeMin"
|
||||
type="number"
|
||||
bind:value={cookTimeMin}
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Effort chips -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">
|
||||
Schwierigkeitsgrad
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each effortOptions as opt (opt.value)}
|
||||
<label
|
||||
class={[
|
||||
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
|
||||
effort === opt.value
|
||||
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
|
||||
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
|
||||
].join(' ')}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="effort"
|
||||
value={opt.value}
|
||||
bind:group={effort}
|
||||
class="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
|
||||
<div class="flex flex-col gap-[8px]">
|
||||
{#each ingredients as ing, i (i)}
|
||||
<div class="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={ing.quantity}
|
||||
placeholder="Menge"
|
||||
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ing.unit}
|
||||
placeholder="Einheit"
|
||||
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ing.name}
|
||||
placeholder="Zutat"
|
||||
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}
|
||||
class="shrink-0 text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '' as number | '', unit: '' }])}
|
||||
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
|
||||
>
|
||||
Zutat hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Schritte</p>
|
||||
<div class="flex flex-col gap-[12px]">
|
||||
{#each steps as _, i (i)}
|
||||
<div class="flex items-start gap-[12px]">
|
||||
<span
|
||||
class="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[var(--green-tint)] text-[12px] font-medium text-[var(--green-dark)]"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div class="flex flex-1 flex-col gap-[6px]">
|
||||
<textarea
|
||||
bind:value={steps[i]}
|
||||
placeholder="Schritt beschreiben…"
|
||||
rows="3"
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (steps = steps.filter((_, j) => j !== i))}
|
||||
class="self-start text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (steps = [...steps, ''])}
|
||||
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
|
||||
>
|
||||
Schritt hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: categories -->
|
||||
<div class="md:w-[280px] md:flex-shrink-0 mt-[24px] md:mt-0">
|
||||
<div
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
|
||||
>
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each categories as cat (cat.id)}
|
||||
<label
|
||||
class={[
|
||||
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
|
||||
selectedTagIds.includes(cat.id)
|
||||
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
|
||||
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
|
||||
].join(' ')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="tagIds"
|
||||
value={cat.id}
|
||||
checked={selectedTagIds.includes(cat.id)}
|
||||
onchange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
selectedTagIds = [...selectedTagIds, cat.id];
|
||||
} else {
|
||||
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
|
||||
}
|
||||
}}
|
||||
class="sr-only"
|
||||
/>
|
||||
{cat.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden inputs for form submission -->
|
||||
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
|
||||
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-[32px] flex items-center justify-between">
|
||||
<a
|
||||
href="/recipes"
|
||||
class="text-[13px] font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
165
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
165
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { writable } from 'svelte/store';
|
||||
import RecipeForm from './RecipeForm.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: writable({ form: null, url: new URL('http://localhost/recipes/new') })
|
||||
}));
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
const mockCategories = [
|
||||
{ id: 'c1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 'c2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
const emptyProps = {
|
||||
recipe: null,
|
||||
categories: mockCategories,
|
||||
action: '?/create'
|
||||
};
|
||||
|
||||
const editProps = {
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Medium',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
ingredients: [
|
||||
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||
],
|
||||
steps: [
|
||||
{ instruction: 'Wasser aufsetzen' }
|
||||
],
|
||||
tagIds: ['c1']
|
||||
},
|
||||
categories: mockCategories,
|
||||
action: '?/update'
|
||||
};
|
||||
|
||||
describe('RecipeForm', () => {
|
||||
it('renders recipe name input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders serves input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/portionen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cook time input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/kochzeit/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills name when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
it('prefills serves when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByLabelText(/portionen/i)).toHaveValue(4);
|
||||
});
|
||||
|
||||
it('renders effort chips', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('radio', { name: /leicht/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /mittel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /schwer/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills effort when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByRole('radio', { name: /mittel/i })).toBeChecked();
|
||||
});
|
||||
|
||||
it('renders category chips', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills selected categories when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders at least one ingredient row initially for empty form', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByPlaceholderText(/zutat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills ingredient rows when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByDisplayValue('Spaghetti')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds ingredient row when "Zutat hinzufügen" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const before = screen.getAllByPlaceholderText(/zutat/i).length;
|
||||
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
|
||||
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('removes ingredient row when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: editProps });
|
||||
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
|
||||
const before = screen.getAllByPlaceholderText(/zutat/i).length;
|
||||
const removeButtons = screen.getAllByRole('button', { name: /entfernen/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before - 1);
|
||||
});
|
||||
|
||||
it('renders at least one step row initially for empty form', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByPlaceholderText(/schritt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills step rows when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByDisplayValue('Wasser aufsetzen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds step row when "Schritt hinzufügen" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const before = screen.getAllByPlaceholderText(/schritt/i).length;
|
||||
await user.click(screen.getByRole('button', { name: /schritt hinzufügen/i }));
|
||||
expect(screen.getAllByPlaceholderText(/schritt/i)).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('renders save button', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('button', { name: /speichern/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cancel link back to /recipes', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const cancelLink = screen.getByRole('link', { name: /abbrechen/i });
|
||||
expect(cancelLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('displays form error message when $page.form.error is set', async () => {
|
||||
const { page } = await import('$app/stores');
|
||||
(page as ReturnType<typeof writable>).set({ form: { error: 'Name ist erforderlich' }, url: new URL('http://localhost/recipes/new') });
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Name ist erforderlich');
|
||||
(page as ReturnType<typeof writable>).set({ form: null, url: new URL('http://localhost/recipes/new') });
|
||||
});
|
||||
|
||||
it('does not display error banner when form has no error', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
21
frontend/src/lib/recipes/RecipeGrid.svelte
Normal file
21
frontend/src/lib/recipes/RecipeGrid.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import RecipeCard from './RecipeCard.svelte';
|
||||
import type { RecipeSummary } from './types';
|
||||
|
||||
let { recipes, onplan }: { recipes: RecipeSummary[]; onplan?: (recipeId: string, recipeName: string) => void } = $props();
|
||||
</script>
|
||||
|
||||
{#if recipes.length > 0}
|
||||
<div data-testid="recipe-grid" class="grid grid-cols-2 lg:grid-cols-4 gap-[8px] lg:gap-[12px] p-[16px]">
|
||||
{#each recipes as recipe (recipe.id)}
|
||||
<RecipeCard {recipe} compact={true} {onplan} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-[48px] px-[24px] text-center">
|
||||
<p class="text-[var(--color-text-muted)] text-[14px] mb-[16px]">Noch keine Rezepte vorhanden.</p>
|
||||
<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>
|
||||
{/if}
|
||||
41
frontend/src/lib/recipes/RecipeGrid.test.ts
Normal file
41
frontend/src/lib/recipes/RecipeGrid.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import RecipeGrid from './RecipeGrid.svelte';
|
||||
|
||||
const mockRecipes = [
|
||||
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
||||
{ id: 'r3', name: 'Caesar Salad', cookTimeMin: 15, effort: 'Easy' }
|
||||
];
|
||||
|
||||
describe('RecipeGrid', () => {
|
||||
it('renders a card for each recipe', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Caesar Salad')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 3 links for 3 recipes', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('shows empty state when recipes array is empty', () => {
|
||||
render(RecipeGrid, { props: { recipes: [] } });
|
||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('empty state links to recipe creation', () => {
|
||||
render(RecipeGrid, { props: { recipes: [] } });
|
||||
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
|
||||
expect(addLink).toHaveAttribute('href', '/recipes/new');
|
||||
});
|
||||
|
||||
it('grid has 2-col mobile and 4-col desktop classes', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
const grid = document.querySelector('[data-testid="recipe-grid"]');
|
||||
expect(grid?.className).toContain('grid-cols-2');
|
||||
expect(grid?.className).toContain('lg:grid-cols-4');
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { Tag } from './types';
|
||||
|
||||
type RecipeHeroData = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
let { recipe }: { recipe: RecipeHeroData } = $props();
|
||||
|
||||
let hasImage = $derived(!!recipe.heroImageUrl);
|
||||
|
||||
let pillBase = $derived(
|
||||
hasImage
|
||||
? 'bg-white/20 text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
: 'bg-[var(--color-border)] text-[var(--color-text-muted)] text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
);
|
||||
|
||||
let cookBtnClass = $derived(
|
||||
hasImage
|
||||
? 'font-sans text-[13px] font-medium tracking-[0.04em] bg-white text-[var(--green-dark)] rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
: 'font-sans text-[13px] font-medium tracking-[0.04em] bg-[var(--green-dark)] text-white rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="recipe-hero"
|
||||
class="min-h-[200px] md:min-h-[240px] {hasImage
|
||||
? 'relative text-white'
|
||||
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
|
||||
>
|
||||
{#if hasImage}
|
||||
<img
|
||||
src={recipe.heroImageUrl}
|
||||
alt={recipe.name}
|
||||
class="object-cover w-full h-full absolute inset-0"
|
||||
/>
|
||||
<div class="absolute inset-0" style="background: rgba(0,0,0,0.5);"></div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<a href="/recipes" class="text-[13px] font-sans font-medium text-[var(--color-text-muted)]">← Zurück</a>
|
||||
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] mt-[8px]">
|
||||
{recipe.name}
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-[8px] flex-wrap mt-[12px]">
|
||||
{#if recipe.cookTimeMin != null}
|
||||
<span class={pillBase}>{recipe.cookTimeMin} Min</span>
|
||||
{/if}
|
||||
{#if recipe.effort}
|
||||
<span class={pillBase}>{recipe.effort}</span>
|
||||
{/if}
|
||||
{#if recipe.serves != null}
|
||||
<span class={pillBase}>{recipe.serves} Port.</span>
|
||||
{/if}
|
||||
{#each recipe.tags as tag (tag.id)}
|
||||
<span class={pillBase}>{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
|
||||
</div>
|
||||
</div>
|
||||
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import RecipeHero from './RecipeHero.svelte';
|
||||
|
||||
const baseRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
tags: [] as { id: string; name: string; tagType?: string }[]
|
||||
};
|
||||
|
||||
describe('RecipeHero', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders green-tint hero when no image', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const hero = document.querySelector('[data-testid="recipe-hero"]');
|
||||
expect(hero?.className).toContain('bg-[var(--green-tint)]');
|
||||
});
|
||||
|
||||
it('renders image when heroImageUrl is provided', () => {
|
||||
render(RecipeHero, {
|
||||
props: { recipe: { ...baseRecipe, heroImageUrl: '/uploads/pasta.jpg' } }
|
||||
});
|
||||
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||
});
|
||||
|
||||
it('renders cook time pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/30 Min/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders effort pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/Easy/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders serves pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders back link to /recipes', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('renders cook now link to /cook/[id]', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||
});
|
||||
|
||||
it('does not render img when no heroImageUrl', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tag pills', () => {
|
||||
render(RecipeHero, {
|
||||
props: {
|
||||
recipe: {
|
||||
...baseRecipe,
|
||||
tags: [
|
||||
{ id: 't1', name: 'Pasta' },
|
||||
{ id: 't2', name: 'Italienisch' }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Italienisch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no tag pills when tags array is empty', () => {
|
||||
render(RecipeHero, { props: { recipe: { ...baseRecipe, tags: [] } } });
|
||||
expect(screen.queryByText('Pasta')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/recipes/StepList.svelte
Normal file
33
frontend/src/lib/recipes/StepList.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Step } from './types';
|
||||
|
||||
let { steps }: { steps: Step[] } = $props();
|
||||
|
||||
const sortedSteps = $derived(
|
||||
[...steps].sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
|
||||
);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||
>
|
||||
Zubereitung
|
||||
</h2>
|
||||
<ol>
|
||||
{#each sortedSteps as step (step.stepNumber)}
|
||||
<li class="flex gap-[16px] items-start mb-[20px]">
|
||||
<div
|
||||
data-testid="step-circle"
|
||||
aria-hidden="true"
|
||||
class="w-[28px] h-[28px] rounded-full bg-[var(--green-dark)] text-white flex items-center justify-center shrink-0 font-sans text-[13px] font-medium"
|
||||
>
|
||||
{step.stepNumber}
|
||||
</div>
|
||||
<p class="text-[14px] text-[var(--color-text)] leading-[1.6] pt-[4px]">
|
||||
{step.instruction}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import StepList from './StepList.svelte';
|
||||
|
||||
const mockSteps = [
|
||||
{ stepNumber: 1, instruction: 'Wasser zum Kochen bringen' },
|
||||
{ stepNumber: 2, instruction: 'Spaghetti al dente kochen' },
|
||||
{ stepNumber: 3, instruction: 'Sauce bereiten' }
|
||||
];
|
||||
|
||||
describe('StepList', () => {
|
||||
it('renders the section heading', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders each step instruction', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText('Wasser zum Kochen bringen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spaghetti al dente kochen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sauce bereiten')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step numbers', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders numbered circles with step numbers', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||
expect(circles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders steps in stepNumber order', () => {
|
||||
const shuffled = [mockSteps[2], mockSteps[0], mockSteps[1]];
|
||||
render(StepList, { props: { steps: shuffled } });
|
||||
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||
expect(circles[0].textContent).toBe('1');
|
||||
expect(circles[1].textContent).toBe('2');
|
||||
expect(circles[2].textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('renders empty state when no steps', () => {
|
||||
render(StepList, { props: { steps: [] } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/recipes/types.ts
Normal file
38
frontend/src/lib/recipes/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type RecipeSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
name: string;
|
||||
tagType?: string;
|
||||
};
|
||||
|
||||
export type Ingredient = {
|
||||
ingredientId?: string;
|
||||
name?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type Step = {
|
||||
stepNumber?: number;
|
||||
instruction?: string;
|
||||
};
|
||||
|
||||
export type RecipeDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
ingredients: Ingredient[];
|
||||
steps: Step[];
|
||||
tags: Tag[];
|
||||
};
|
||||
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 };
|
||||
}
|
||||
88
frontend/src/lib/shopping/AddCustomItem.svelte
Normal file
88
frontend/src/lib/shopping/AddCustomItem.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
listId: string;
|
||||
}
|
||||
|
||||
let { listId }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let customName = $state('');
|
||||
let quantity = $state('1');
|
||||
let unit = $state('');
|
||||
</script>
|
||||
|
||||
{#if !expanded}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = true)}
|
||||
class="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:border-[var(--green-light)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
<span class="text-[16px]">+</span>
|
||||
Artikel hinzufügen
|
||||
</button>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') {
|
||||
await update();
|
||||
customName = '';
|
||||
quantity = '1';
|
||||
unit = '';
|
||||
expanded = false;
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3"
|
||||
>
|
||||
<input type="hidden" name="listId" value={listId} />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="customName"
|
||||
bind:value={customName}
|
||||
placeholder="Artikelname"
|
||||
required
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
bind:value={quantity}
|
||||
min="0"
|
||||
step="any"
|
||||
class="w-20 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] focus:border-[var(--green)] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="unit"
|
||||
bind:value={unit}
|
||||
placeholder="Einheit"
|
||||
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = false)}
|
||||
class="rounded-[var(--radius-md)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!customName.trim()}
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
58
frontend/src/lib/shopping/AddCustomItem.test.ts
Normal file
58
frontend/src/lib/shopping/AddCustomItem.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import AddCustomItem from './AddCustomItem.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
describe('AddCustomItem', () => {
|
||||
it('shows collapsed trigger button initially', () => {
|
||||
render(AddCustomItem, { props: { listId: 'list-1' } });
|
||||
expect(screen.getByText(/Artikel hinzufügen/)).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands form when trigger button is clicked', async () => {
|
||||
render(AddCustomItem, { props: { listId: 'list-1' } });
|
||||
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
|
||||
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses form when Abbrechen is clicked', async () => {
|
||||
render(AddCustomItem, { props: { listId: 'list-1' } });
|
||||
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
|
||||
expect(screen.getByPlaceholderText('Artikelname')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('Abbrechen'));
|
||||
expect(screen.queryByPlaceholderText('Artikelname')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submit button is disabled when name is empty', async () => {
|
||||
render(AddCustomItem, { props: { listId: 'list-1' } });
|
||||
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
|
||||
expect(screen.getByRole('button', { name: /Hinzufügen/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('submit button is enabled when name is entered', async () => {
|
||||
render(AddCustomItem, { props: { listId: 'list-1' } });
|
||||
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
|
||||
await userEvent.type(screen.getByPlaceholderText('Artikelname'), 'Papier');
|
||||
expect(screen.getByRole('button', { name: /Hinzufügen/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('form submits to ?/addItem action', async () => {
|
||||
render(AddCustomItem, { props: { listId: 'list-1' } });
|
||||
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
|
||||
const form = screen.getByPlaceholderText('Artikelname').closest('form')!;
|
||||
expect(form).toHaveAttribute('action', '?/addItem');
|
||||
});
|
||||
|
||||
it('form includes listId as hidden input', async () => {
|
||||
render(AddCustomItem, { props: { listId: 'list-42' } });
|
||||
await userEvent.click(screen.getByText(/Artikel hinzufügen/));
|
||||
const form = screen.getByPlaceholderText('Artikelname').closest('form')!;
|
||||
const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement;
|
||||
expect(listIdInput.value).toBe('list-42');
|
||||
});
|
||||
});
|
||||
81
frontend/src/lib/shopping/ChecklistItem.svelte
Normal file
81
frontend/src/lib/shopping/ChecklistItem.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface RecipeRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
listId: string;
|
||||
itemId: string;
|
||||
name: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
isChecked: boolean;
|
||||
sourceRecipes: RecipeRef[];
|
||||
}
|
||||
|
||||
let { listId, itemId, name, quantity, unit, isChecked, sourceRecipes }: Props = $props();
|
||||
|
||||
let recipeLabel = $derived(
|
||||
sourceRecipes.length > 0
|
||||
? sourceRecipes
|
||||
.map((r) => r.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: null
|
||||
);
|
||||
|
||||
let quantityLabel = $derived(
|
||||
quantity ? `${quantity}${unit ? ` ${unit}` : ''}` : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/check" use:enhance={() => async ({ update }) => update({ reset: false })} class="group flex items-center gap-3 py-2">
|
||||
<input type="hidden" name="listId" value={listId} />
|
||||
<input type="hidden" name="itemId" value={itemId} />
|
||||
<input type="hidden" name="isChecked" value={!isChecked} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
role="checkbox"
|
||||
aria-checked={isChecked}
|
||||
aria-label="{isChecked ? 'Abhaken rückgängig' : 'Abhaken'}: {name}"
|
||||
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--green)]
|
||||
{isChecked
|
||||
? 'border-[var(--green)] bg-[var(--green)] text-white'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--green-light)]'}"
|
||||
>
|
||||
{#if isChecked}
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 6l3 3 5-5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="font-[var(--font-sans)] text-[14px] {isChecked
|
||||
? 'text-[var(--color-text-muted)] line-through'
|
||||
: 'text-[var(--color-text)]'}"
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
{#if recipeLabel && !isChecked}
|
||||
<p class="truncate font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
||||
Für: {recipeLabel}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if quantityLabel}
|
||||
<span
|
||||
class="flex-shrink-0 font-[var(--font-sans)] text-[13px] {isChecked
|
||||
? 'text-[var(--color-text-muted)]'
|
||||
: 'text-[var(--color-text)]'}"
|
||||
>
|
||||
{quantityLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</form>
|
||||
88
frontend/src/lib/shopping/ChecklistItem.test.ts
Normal file
88
frontend/src/lib/shopping/ChecklistItem.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import ChecklistItem from './ChecklistItem.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
describe('ChecklistItem', () => {
|
||||
const baseProps = {
|
||||
listId: 'list-1',
|
||||
itemId: 'item-1',
|
||||
name: 'Tomaten',
|
||||
quantity: null,
|
||||
unit: null,
|
||||
isChecked: false,
|
||||
sourceRecipes: []
|
||||
};
|
||||
|
||||
it('renders the item name', () => {
|
||||
render(ChecklistItem, { props: baseProps });
|
||||
expect(screen.getByText('Tomaten')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quantity and unit when provided', () => {
|
||||
render(ChecklistItem, { props: { ...baseProps, quantity: 3, unit: 'Stück' } });
|
||||
expect(screen.getByText('3 Stück')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quantity without unit when unit is null', () => {
|
||||
render(ChecklistItem, { props: { ...baseProps, quantity: 2, unit: null } });
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies line-through style when checked', () => {
|
||||
render(ChecklistItem, { props: { ...baseProps, isChecked: true } });
|
||||
const nameEl = screen.getByText('Tomaten');
|
||||
expect(nameEl.className).toContain('line-through');
|
||||
});
|
||||
|
||||
it('does not apply line-through when unchecked', () => {
|
||||
render(ChecklistItem, { props: { ...baseProps, isChecked: false } });
|
||||
const nameEl = screen.getByText('Tomaten');
|
||||
expect(nameEl.className).not.toContain('line-through');
|
||||
});
|
||||
|
||||
it('shows recipe label for source recipes when unchecked', () => {
|
||||
render(ChecklistItem, {
|
||||
props: {
|
||||
...baseProps,
|
||||
sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }]
|
||||
}
|
||||
});
|
||||
expect(screen.getByText(/Für: Spaghetti/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides recipe label when item is checked', () => {
|
||||
render(ChecklistItem, {
|
||||
props: {
|
||||
...baseProps,
|
||||
isChecked: true,
|
||||
sourceRecipes: [{ id: 'r-1', name: 'Spaghetti' }]
|
||||
}
|
||||
});
|
||||
expect(screen.queryByText(/Für:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets aria-checked to false when unchecked', () => {
|
||||
render(ChecklistItem, { props: { ...baseProps, isChecked: false } });
|
||||
const button = screen.getByRole('checkbox');
|
||||
expect(button).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('sets aria-checked to true when checked', () => {
|
||||
render(ChecklistItem, { props: { ...baseProps, isChecked: true } });
|
||||
const button = screen.getByRole('checkbox');
|
||||
expect(button).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('passes listId and itemId as hidden inputs', () => {
|
||||
render(ChecklistItem, { props: baseProps });
|
||||
const form = screen.getByRole('checkbox').closest('form')!;
|
||||
const listIdInput = form.querySelector('input[name="listId"]') as HTMLInputElement;
|
||||
const itemIdInput = form.querySelector('input[name="itemId"]') as HTMLInputElement;
|
||||
expect(listIdInput.value).toBe('list-1');
|
||||
expect(itemIdInput.value).toBe('item-1');
|
||||
});
|
||||
});
|
||||
62
frontend/src/lib/shopping/RecipeReferencePanel.svelte
Normal file
62
frontend/src/lib/shopping/RecipeReferencePanel.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { formatDayAbbr } from '$lib/planner/week';
|
||||
|
||||
interface Slot {
|
||||
slotDate?: string;
|
||||
recipe?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slots: Slot[];
|
||||
filteredStaplesCount: number;
|
||||
}
|
||||
|
||||
let { slots, filteredStaplesCount }: Props = $props();
|
||||
|
||||
let filledSlots = $derived(slots.filter((s) => s.recipe));
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Rezepte dieser Woche
|
||||
</h2>
|
||||
<div class="mt-2 space-y-1.5">
|
||||
{#each filledSlots as slot}
|
||||
<a
|
||||
href="/recipes/{slot.recipe?.id}"
|
||||
class="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2 hover:border-[var(--green-light)]"
|
||||
>
|
||||
<span class="min-w-[28px] font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : ''}
|
||||
</span>
|
||||
<span class="flex-1 truncate font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||
{slot.recipe?.name}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if filledSlots.length === 0}
|
||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
Keine Gerichte geplant.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredStaplesCount > 0}
|
||||
<div class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2">
|
||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{filteredStaplesCount} Grundzutaten automatisch ausgeblendet
|
||||
</p>
|
||||
<a
|
||||
href="/pantry"
|
||||
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] hover:underline"
|
||||
>
|
||||
Vorrat bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
92
frontend/src/lib/shopping/ShoppingChecklist.svelte
Normal file
92
frontend/src/lib/shopping/ShoppingChecklist.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import ChecklistItem from '$lib/shopping/ChecklistItem.svelte';
|
||||
import AddCustomItem from '$lib/shopping/AddCustomItem.svelte';
|
||||
|
||||
interface RecipeRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id?: string;
|
||||
name?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
isChecked?: boolean;
|
||||
sourceRecipes?: RecipeRef[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
listId: string;
|
||||
uncheckedItems: Item[];
|
||||
checkedItems: Item[];
|
||||
totalItems: number;
|
||||
filteredStaplesCount?: number;
|
||||
showFilteredStaples?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
listId,
|
||||
uncheckedItems,
|
||||
checkedItems,
|
||||
totalItems,
|
||||
filteredStaplesCount = 0,
|
||||
showFilteredStaples = false
|
||||
}: Props = $props();
|
||||
|
||||
let checkedCount = $derived(checkedItems.length);
|
||||
</script>
|
||||
|
||||
{#if uncheckedItems.length > 0}
|
||||
<div class="divide-y divide-[var(--color-border)]">
|
||||
{#each uncheckedItems as item (item.id)}
|
||||
<ChecklistItem
|
||||
{listId}
|
||||
itemId={item.id ?? ''}
|
||||
name={item.name ?? ''}
|
||||
quantity={item.quantity ?? null}
|
||||
unit={item.unit ?? null}
|
||||
isChecked={false}
|
||||
sourceRecipes={item.sourceRecipes ?? []}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if totalItems > 0}
|
||||
<p class="py-4 text-center font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Alles erledigt!
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3">
|
||||
<AddCustomItem {listId} />
|
||||
</div>
|
||||
|
||||
{#if showFilteredStaples && filteredStaplesCount > 0}
|
||||
<div class="mt-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
|
||||
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{filteredStaplesCount} Grundzutaten ausgeblendet ·
|
||||
<a href="/pantry" class="font-medium text-[var(--green-dark)] hover:underline">Vorrat bearbeiten</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if checkedItems.length > 0}
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Abgehakt ({checkedCount})
|
||||
</p>
|
||||
<div class="divide-y divide-[var(--color-border)]">
|
||||
{#each checkedItems as item (item.id)}
|
||||
<ChecklistItem
|
||||
{listId}
|
||||
itemId={item.id ?? ''}
|
||||
name={item.name ?? ''}
|
||||
quantity={item.quantity ?? null}
|
||||
unit={item.unit ?? null}
|
||||
isChecked={true}
|
||||
sourceRecipes={item.sourceRecipes ?? []}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
67
frontend/src/lib/shopping/ShoppingHeader.svelte
Normal file
67
frontend/src/lib/shopping/ShoppingHeader.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
totalItems: number;
|
||||
checkedCount: number;
|
||||
generatedAt: string | null;
|
||||
weekPlanId: string | null;
|
||||
isPlanner: boolean;
|
||||
hasShoppingList: boolean;
|
||||
}
|
||||
|
||||
let { totalItems, checkedCount, generatedAt, weekPlanId, isPlanner, hasShoppingList }: Props = $props();
|
||||
|
||||
let remainingCount = $derived(totalItems - checkedCount);
|
||||
|
||||
let generating = $state(false);
|
||||
|
||||
let formattedTime = $derived(
|
||||
generatedAt
|
||||
? new Date(generatedAt).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<header class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
||||
Einkaufsliste
|
||||
</h1>
|
||||
|
||||
{#if isPlanner && weekPlanId}
|
||||
<form method="POST" action="?/generate" use:enhance={() => {
|
||||
generating = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
generating = false;
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="weekPlanId" value={weekPlanId} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generating}
|
||||
class="rounded-[var(--radius-md)] {hasShoppingList
|
||||
? 'border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]'
|
||||
: 'bg-[var(--green-dark)] text-white'} px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] disabled:opacity-50"
|
||||
>
|
||||
{generating ? '…' : hasShoppingList ? 'Neu generieren' : 'Liste generieren'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasShoppingList}
|
||||
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{remainingCount} Artikel übrig · {checkedCount} abgehakt
|
||||
{#if formattedTime}
|
||||
<span class="ml-1">· erstellt {formattedTime}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
71
frontend/src/lib/shopping/ShoppingHeader.test.ts
Normal file
71
frontend/src/lib/shopping/ShoppingHeader.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import ShoppingHeader from './ShoppingHeader.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
describe('ShoppingHeader', () => {
|
||||
const baseProps = {
|
||||
totalItems: 0,
|
||||
checkedCount: 0,
|
||||
generatedAt: null,
|
||||
weekPlanId: null,
|
||||
isPlanner: false,
|
||||
hasShoppingList: false
|
||||
};
|
||||
|
||||
it('renders the heading', () => {
|
||||
render(ShoppingHeader, { props: baseProps });
|
||||
expect(screen.getByText('Einkaufsliste')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows counts when hasShoppingList is true', () => {
|
||||
render(ShoppingHeader, {
|
||||
props: { ...baseProps, totalItems: 5, checkedCount: 2, hasShoppingList: true }
|
||||
});
|
||||
expect(screen.getByText(/3 Artikel übrig/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 abgehakt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show counts when hasShoppingList is false', () => {
|
||||
render(ShoppingHeader, { props: { ...baseProps, hasShoppingList: false } });
|
||||
expect(screen.queryByText(/Artikel übrig/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows generate button for planner with weekPlanId', () => {
|
||||
render(ShoppingHeader, {
|
||||
props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1' }
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Liste generieren/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows regenerate button when planner already has a list', () => {
|
||||
render(ShoppingHeader, {
|
||||
props: { ...baseProps, isPlanner: true, weekPlanId: 'plan-1', hasShoppingList: true, totalItems: 3, checkedCount: 0 }
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Neu generieren/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides generate button for non-planner', () => {
|
||||
render(ShoppingHeader, {
|
||||
props: { ...baseProps, isPlanner: false, weekPlanId: 'plan-1' }
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides generate button when weekPlanId is null', () => {
|
||||
render(ShoppingHeader, {
|
||||
props: { ...baseProps, isPlanner: true, weekPlanId: null }
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /generieren/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows formatted timestamp when generatedAt is provided', () => {
|
||||
render(ShoppingHeader, {
|
||||
props: { ...baseProps, hasShoppingList: true, generatedAt: '2026-04-06T10:30:00Z', totalItems: 1, checkedCount: 0 }
|
||||
});
|
||||
expect(screen.getByText(/erstellt/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
74
frontend/src/routes/(app)/planner/+page.server.ts
Normal file
74
frontend/src/routes/(app)/planner/+page.server.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const [weekPlanResult, recipesResult] = await Promise.all([
|
||||
api.GET('/v1/week-plans', { params: { query: { weekStart } } }),
|
||||
api.GET('/v1/recipes', {})
|
||||
]);
|
||||
|
||||
const recipes =
|
||||
recipesResult.error || !recipesResult.data?.data
|
||||
? []
|
||||
: recipesResult.data.data.map((r: any) => ({
|
||||
id: r.id!,
|
||||
name: r.name!,
|
||||
cookTimeMin: r.cookTimeMin,
|
||||
effort: r.effort,
|
||||
heroImageUrl: r.heroImageUrl
|
||||
}));
|
||||
|
||||
if (weekPlanResult.error || !weekPlanResult.data?.id) {
|
||||
return { weekPlan: null, varietyScore: null, weekStart, recipes };
|
||||
}
|
||||
|
||||
const weekPlan = weekPlanResult.data;
|
||||
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
|
||||
params: { path: { id: weekPlan.id! } }
|
||||
});
|
||||
|
||||
return {
|
||||
weekPlan,
|
||||
varietyScore: varietyScore ?? null,
|
||||
weekStart,
|
||||
recipes
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addSlot: addSlotAction,
|
||||
updateSlot: updateSlotAction,
|
||||
deleteSlot: deleteSlotAction,
|
||||
|
||||
createPlan: async ({ fetch, request, locals }) => {
|
||||
// Role guard: only planners may create week plans
|
||||
if (locals.benutzer?.rolle !== 'planer') {
|
||||
return { success: false, error: 'Keine Berechtigung.' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const weekStart = formData.get('weekStart') as string;
|
||||
|
||||
// Validate weekStart format: must be YYYY-MM-DD
|
||||
if (!weekStart || !/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) {
|
||||
return { success: false, error: 'Ungültiges Datum.' };
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans', {
|
||||
body: { weekStart }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Plan konnte nicht erstellt werden.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -1 +1,720 @@
|
||||
<h1 class="text-2xl font-medium p-6">Planer</h1>
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||
import RecipePicker from '$lib/planner/RecipePicker.svelte';
|
||||
import MealActionSheet from '$lib/planner/MealActionSheet.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 type { Suggestion } from '$lib/planner/types';
|
||||
|
||||
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
|
||||
|
||||
// Use UTC date string (YYYY-MM-DD) consistently
|
||||
const today: string = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let weekStart = $derived(data.weekStart);
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let varietyScore = $derived(data.varietyScore);
|
||||
|
||||
let days = $derived(weekDays(weekStart));
|
||||
let slots = $derived(weekPlan?.slots ?? []);
|
||||
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
||||
|
||||
// Default selected day: today if in this week, else first day
|
||||
// We read data.weekStart once synchronously here (before reactivity kicks in) to seed the initial value.
|
||||
let selectedDay = $state((() => {
|
||||
const init = data.weekStart;
|
||||
const d = weekDays(init);
|
||||
return d.includes(today) ? today : d[0];
|
||||
})());
|
||||
|
||||
// When week changes via navigation, reset selected day
|
||||
$effect(() => {
|
||||
const newDays = weekDays(weekStart);
|
||||
if (!newDays.includes(selectedDay)) {
|
||||
selectedDay = newDays.includes(today) ? today : newDays[0];
|
||||
}
|
||||
});
|
||||
|
||||
let selectedSlot = $derived(slotMap[selectedDay] ?? { id: null, slotDate: selectedDay, recipe: null });
|
||||
let remainingSlots = $derived(days.filter((d: string) => d > selectedDay).map((d: string) => slotMap[d] ?? { id: null, slotDate: d, recipe: null }));
|
||||
let remainingSlotsWithMeal = $derived(remainingSlots.filter((s: any) => s.recipe));
|
||||
|
||||
let isPlanner = $derived((data as any).benutzer?.rolle === 'planer');
|
||||
|
||||
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 (empty slot) and swap flow
|
||||
let pickerOpen = $state(false);
|
||||
let actionSheetOpen = $state(false);
|
||||
let swapSheetOpen = $state(false);
|
||||
let swapLoading = $state(false);
|
||||
|
||||
const activePickerDate = $derived(
|
||||
pickerOpen ? selectedDay
|
||||
: swapSheetOpen ? selectedDay
|
||||
: panelState.kind === 'recipe-picker' ? panelState.date
|
||||
: null
|
||||
);
|
||||
|
||||
let suggestions: Suggestion[] = $state([]);
|
||||
let isLoadingSuggestions = $state(false);
|
||||
|
||||
// Recipes already in any slot this week — used for ⚠ overlap warnings
|
||||
let currentWeekRecipeIds = $derived(
|
||||
new Set<string>(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id))
|
||||
);
|
||||
|
||||
// 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('');
|
||||
let undoCallback = $state<(() => void) | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!activePickerDate || !weekPlan?.id) {
|
||||
suggestions = [];
|
||||
isLoadingSuggestions = false;
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
isLoadingSuggestions = true;
|
||||
fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal })
|
||||
.then((r) => r.json())
|
||||
.then((d) => { suggestions = d.suggestions ?? []; })
|
||||
.catch((e) => { if (e.name !== 'AbortError') suggestions = []; })
|
||||
.finally(() => { isLoadingSuggestions = false; });
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
function handleSelectDay(day: string) {
|
||||
selectedDay = day;
|
||||
panelState = { kind: 'day-detail', date: day };
|
||||
}
|
||||
|
||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||
let newWeekStart: string;
|
||||
if (direction === 'prev') newWeekStart = prevWeek(weekStart);
|
||||
else if (direction === 'next') newWeekStart = nextWeek(weekStart);
|
||||
else newWeekStart = getWeekStart(new Date());
|
||||
|
||||
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;
|
||||
undoCallback?.();
|
||||
}
|
||||
|
||||
async function handleRemoveMeal(slot: { id: string; slotDate: string; recipe: { id: string; name: string } | null }) {
|
||||
// Capture primitive values immediately — slot may be a reactive proxy that
|
||||
// becomes stale after the first await (tick flushes state + re-render).
|
||||
const slotId = slot.id;
|
||||
const slotDate = slot.slotDate;
|
||||
const recipeName = slot.recipe?.name ?? '';
|
||||
const recipeId = slot.recipe?.id ?? '';
|
||||
if (!slotId || !recipeId) return;
|
||||
|
||||
actionSheetOpen = false;
|
||||
undoCallback = async () => {
|
||||
addPlanId = weekPlan!.id;
|
||||
addSlotDate = slotDate;
|
||||
addRecipeId = recipeId;
|
||||
addRecipeName = recipeName;
|
||||
await tick();
|
||||
addSlotFormEl.requestSubmit();
|
||||
};
|
||||
delPlanId = weekPlan!.id;
|
||||
delSlotId = slotId;
|
||||
await tick();
|
||||
deleteSlotFormEl.requestSubmit();
|
||||
undoMessage = `${recipeName} entfernt`;
|
||||
undoVisible = true;
|
||||
}
|
||||
|
||||
async function handleSwapPick(recipeId: string, recipeName: string) {
|
||||
swapLoading = true;
|
||||
await handleRecipePick(recipeId, recipeName);
|
||||
swapSheetOpen = false;
|
||||
swapLoading = false;
|
||||
}
|
||||
|
||||
function closePanelToIdle() {
|
||||
panelState = { kind: 'idle' };
|
||||
}
|
||||
|
||||
function closePanelToDayDetail() {
|
||||
if (panelState.kind === 'recipe-picker') {
|
||||
panelState = { kind: 'day-detail', date: panelState.date };
|
||||
} else {
|
||||
panelState = { kind: 'idle' };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile & Tablet: vertical stack -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<!-- Top nav (sticky) -->
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Diese Woche</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('prev')}
|
||||
aria-label="Vorherige Woche"
|
||||
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
|
||||
type="button"
|
||||
onclick={() => navigateWeek('next')}
|
||||
aria-label="Nächste Woche"
|
||||
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (pickerOpen = true)}
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
+ Gericht
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Variety banner: sticky below the top nav so it's always visible (spec requirement) -->
|
||||
{#if varietyScore}
|
||||
<div class="sticky z-10 px-4 pt-3" style="top: 56px;">
|
||||
<VarietyScoreCard
|
||||
score={varietyScore.score ?? 0}
|
||||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||||
showReviewLink={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day strip -->
|
||||
<div class="px-4 pt-3">
|
||||
<WeekStrip
|
||||
{weekStart}
|
||||
{slots}
|
||||
{selectedDay}
|
||||
{today}
|
||||
onselectDay={handleSelectDay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected day card -->
|
||||
<div class="px-4 pt-4">
|
||||
<p class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(selectedDay)}
|
||||
</p>
|
||||
<DayMealCard
|
||||
slot={selectedSlot}
|
||||
isToday={selectedDay === today}
|
||||
isSelected={true}
|
||||
readonly={!isPlanner}
|
||||
onactionsheet={isPlanner && selectedSlot.recipe ? () => (actionSheetOpen = true) : undefined}
|
||||
onaddrecipe={isPlanner && !selectedSlot.recipe ? () => (pickerOpen = true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Remaining days list -->
|
||||
{#if remainingSlotsWithMeal.length > 0}
|
||||
<div class="px-4 pt-6 pb-4">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Restliche Woche
|
||||
</h2>
|
||||
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
|
||||
{#each remainingSlotsWithMeal as slot (slot.slotDate)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectDay(slot.slotDate)}
|
||||
class="flex w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left hover:border-[var(--green-light)]"
|
||||
>
|
||||
<span class="min-w-[36px] font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(slot.slotDate).split(',')[0]}
|
||||
</span>
|
||||
<span class="flex-1 truncate font-[var(--font-sans)] text-[14px] font-medium text-[var(--color-text)]">
|
||||
{slot.recipe?.name}
|
||||
</span>
|
||||
{#if isPlanner}
|
||||
<span class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">→</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty week state -->
|
||||
{#if !weekPlan}
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-4 py-8 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||||
{#if isPlanner}
|
||||
<form method="POST" action="?/createPlan" class="mt-4">
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button
|
||||
type="submit"
|
||||
class="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"
|
||||
>
|
||||
Wochenplan erstellen
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile: empty slot → RecipePicker -->
|
||||
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)}>
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={selectedDay}
|
||||
dateLabel={formatDayLabel(selectedDay)}
|
||||
suggestions={suggestions}
|
||||
allRecipes={data.recipes}
|
||||
isLoading={isLoadingSuggestions}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</BottomSheet>
|
||||
|
||||
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Remove / Cancel) -->
|
||||
<MealActionSheet
|
||||
open={actionSheetOpen}
|
||||
slot={selectedSlot}
|
||||
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
|
||||
oncancel={() => (actionSheetOpen = false)}
|
||||
onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined}
|
||||
/>
|
||||
|
||||
<!-- Mobile: swap suggestions sheet -->
|
||||
<BottomSheet open={swapSheetOpen} onclose={() => (swapSheetOpen = false)} height="70vh">
|
||||
{@const replacingMeta = [
|
||||
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
|
||||
selectedSlot.recipe?.effort ?? null
|
||||
].filter(Boolean).join(' · ')}
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={selectedDay}
|
||||
dateLabel={formatDayLabel(selectedDay)}
|
||||
suggestions={suggestions}
|
||||
allRecipes={data.recipes}
|
||||
isLoading={isLoadingSuggestions}
|
||||
isDisabled={swapLoading}
|
||||
excludeRecipeId={selectedSlot.recipe?.id}
|
||||
replacingRecipe={selectedSlot.recipe ? { name: selectedSlot.recipe.name, meta: replacingMeta || undefined } : undefined}
|
||||
onpick={handleSwapPick}
|
||||
/>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 3-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">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Wochenplaner</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('prev')}
|
||||
aria-label="Vorherige Woche"
|
||||
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>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{weekRange}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('next')}
|
||||
aria-label="Nächste Woche"
|
||||
class="flex min-h-[40px] min-w-[40px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('today')}
|
||||
class="flex min-h-[40px] items-center rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
</div>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (panelState = { kind: 'recipe-picker', date: selectedDay })}
|
||||
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left sidebar -->
|
||||
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<!-- Variety widget at bottom -->
|
||||
{#if varietyScore}
|
||||
<div class="mt-auto">
|
||||
<VarietyScoreCard
|
||||
score={varietyScore.score ?? 0}
|
||||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||||
showReviewLink={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- Main calendar (only scrollable panel) -->
|
||||
<main class="flex-1 overflow-y-auto p-5">
|
||||
{#if !weekPlan}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||||
{#if isPlanner}
|
||||
<form method="POST" action="?/createPlan" class="mt-4">
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button type="submit" class="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">
|
||||
Wochenplan erstellen
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-7 gap-[8px]">
|
||||
{#each days as day (day)}
|
||||
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
||||
{@const isTodayDay = day === today}
|
||||
{@const isSelectedDay = day === selectedDay}
|
||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
||||
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- Column header: day name + date badge -->
|
||||
<div class="mb-2 flex flex-col items-center gap-1">
|
||||
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{dayAbbr}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
|
||||
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
|
||||
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
|
||||
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
|
||||
>
|
||||
{dateNum}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meal tile -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
handleSelectDay(day);
|
||||
if (!slot.recipe && isPlanner) {
|
||||
panelState = { kind: 'recipe-picker', date: day };
|
||||
}
|
||||
}}
|
||||
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
|
||||
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
|
||||
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
||||
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
|
||||
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
|
||||
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
{slot.recipe.name}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
|
||||
<span class="text-[18px]" aria-hidden="true">+</span>
|
||||
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- 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">
|
||||
{#if panelState.kind === 'idle'}
|
||||
<div class="flex flex-1 flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Tag ausgewählt</p>
|
||||
</div>
|
||||
|
||||
{:else if panelState.kind === 'day-detail'}
|
||||
{@const detailDate = panelState.date}
|
||||
{@const detailSlot = slotMap[detailDate] ?? { id: null, slotDate: detailDate, recipe: null }}
|
||||
|
||||
<!-- Panel header with close button -->
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(detailDate)} · Abendessen
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{#if detailSlot.recipe}
|
||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
||||
{detailSlot.recipe.name}
|
||||
</h2>
|
||||
{#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<a
|
||||
href="/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)]"
|
||||
>
|
||||
Rezept ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/recipes/{detailSlot.recipe.id}/cook"
|
||||
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Koch-Modus
|
||||
</a>
|
||||
{#if isPlanner}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (panelState = { kind: 'recipe-picker', date: detailDate })}
|
||||
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Gericht tauschen
|
||||
</button>
|
||||
{#if detailSlot.id}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
|
||||
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
{/if}
|
||||
{/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}
|
||||
|
||||
{:else if panelState.kind === 'recipe-picker'}
|
||||
{@const pickerDate = panelState.date}
|
||||
{@const pickerSlot = slotMap[pickerDate] ?? null}
|
||||
{@const isSwapContext = !!pickerSlot?.recipe}
|
||||
|
||||
<!-- 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)]">
|
||||
{isSwapContext ? 'Gericht tauschen' : '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>
|
||||
|
||||
{#if isSwapContext}
|
||||
{@const replacingMeta = [
|
||||
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
|
||||
pickerSlot.recipe.effort ?? null
|
||||
].filter(Boolean).join(' · ')}
|
||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={pickerDate}
|
||||
dateLabel={formatDayLabel(pickerDate)}
|
||||
suggestions={suggestions}
|
||||
allRecipes={data.recipes}
|
||||
isLoading={isLoadingSuggestions}
|
||||
excludeRecipeId={pickerSlot.recipe.id}
|
||||
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
|
||||
<RecipePicker
|
||||
planId={weekPlan?.id ?? ''}
|
||||
date={pickerDate}
|
||||
dateLabel={formatDayLabel(pickerDate)}
|
||||
suggestions={suggestions}
|
||||
allRecipes={data.recipes}
|
||||
isLoading={isLoadingSuggestions}
|
||||
onpick={handleRecipePick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden forms for slot mutations -->
|
||||
<div class="hidden">
|
||||
<!-- Add slot -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addSlot"
|
||||
bind:this={addSlotFormEl}
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', addPlanId);
|
||||
formData.set('slotDate', addSlotDate);
|
||||
formData.set('recipeId', addRecipeId);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && result.data?.success) {
|
||||
const slotId = (result.data as any)?.slot?.id ?? '';
|
||||
delPlanId = addPlanId;
|
||||
delSlotId = slotId;
|
||||
undoCallback = () => deleteSlotFormEl.requestSubmit();
|
||||
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 ?? '';
|
||||
undoCallback = () => deleteSlotFormEl.requestSubmit();
|
||||
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)}
|
||||
/>
|
||||
|
||||
28
frontend/src/routes/(app)/planner/+server.ts
Normal file
28
frontend/src/routes/(app)/planner/+server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const api = apiClient(fetch);
|
||||
const { data } = await api.GET('/v1/week-plans/{id}/suggestions', {
|
||||
params: { path: { id: planId }, query: { slotDate: date, topN: 100 } }
|
||||
});
|
||||
|
||||
const suggestions = (data?.suggestions ?? []).sort(
|
||||
(a: any, b: any) => (b.scoreDelta ?? 0) - (a.scoreDelta ?? 0)
|
||||
);
|
||||
|
||||
return json({ suggestions });
|
||||
} catch {
|
||||
return json({ suggestions: [] });
|
||||
}
|
||||
};
|
||||
370
frontend/src/routes/(app)/planner/page.server.test.ts
Normal file
370
frontend/src/routes/(app)/planner/page.server.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
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();
|
||||
const mockPatch = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete })
|
||||
}));
|
||||
|
||||
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
|
||||
const SLOT_UUID = '22222222-2222-2222-2222-222222222222';
|
||||
const RECIPE_UUID = '33333333-3333-3333-3333-333333333333';
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: PLAN_UUID,
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: SLOT_UUID, slotDate: '2026-03-30', recipe: { id: RECIPE_UUID, name: 'Pasta', effort: 'Easy', cookTimeMin: 30 } }
|
||||
]
|
||||
};
|
||||
|
||||
const mockRecipes = [{ id: RECIPE_UUID, name: 'Pasta', cookTimeMin: 30, effort: 'Easy' }];
|
||||
const mockVarietyScore = { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] };
|
||||
|
||||
describe('planner page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
it('fetches week plan for the current week by default', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }) // weekPlan
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) // recipes
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined }); // varietyScore
|
||||
const url = new URL('http://localhost/planner');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) }));
|
||||
});
|
||||
|
||||
it('uses weekStart from URL search params if provided', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner?week=2026-03-30');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) }));
|
||||
});
|
||||
|
||||
it('returns weekPlan with slots in page data', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.id).toBe(PLAN_UUID);
|
||||
expect(result.weekPlan.slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns variety score in page data', async () => {
|
||||
const scoreWithOverlap = { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] };
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: scoreWithOverlap, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.varietyScore.score).toBe(7.5);
|
||||
expect(result.varietyScore.ingredientOverlaps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null weekPlan when API returns 404', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }) // weekPlan 404
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }); // recipes
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the weekStart used for the query', 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?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekStart).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('creates week plan if not found and fetches variety score after creation', async () => {
|
||||
// When no plan exists (404), load should return null weekPlan — creation is done via a POST action, not in load
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 404 } })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
});
|
||||
|
||||
it('returns recipes in page data', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.recipes).toHaveLength(1);
|
||||
expect(result.recipes[0].name).toBe('Pasta');
|
||||
});
|
||||
|
||||
it('returns empty recipes array when recipes API fails', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }) // recipes fail
|
||||
.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.recipes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner page — actions', () => {
|
||||
let actions: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('createPlan action calls POST /v1/week-plans', async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: PLAN_UUID, weekStart: '2026-03-30', slots: [] }, error: undefined });
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ body: { weekStart: '2026-03-30' } }));
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('createPlan action returns error when API fails', async () => {
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('createPlan action returns error for invalid weekStart format', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', 'not-a-date');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('createPlan action returns error when weekStart is missing', async () => {
|
||||
const formData = new FormData();
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
|
||||
});
|
||||
|
||||
it('createPlan action returns permission error for member role', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner page — variety score partial failure', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
it('returns weekPlan even when variety score API fails', async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined })
|
||||
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); // variety score fails
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.id).toBe(PLAN_UUID);
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner page — slot actions', () => {
|
||||
let actions: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockPatch.mockReset();
|
||||
mockDelete.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('addSlot calls POST /v1/week-plans/{id}/slots and returns success with slot', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPost.mockResolvedValue({ data: { id: SLOT_UUID, slotDate: '2026-04-01' }, error: undefined });
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{id}/slots',
|
||||
expect.objectContaining({
|
||||
params: { path: { id: PLAN_UUID } },
|
||||
body: { slotDate: '2026-04-01', recipeId: RECIPE_UUID }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.slot?.id).toBe(SLOT_UUID);
|
||||
});
|
||||
|
||||
it('addSlot returns failure when API errors', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('addSlot returns validation error when planId is not a UUID', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'not-a-uuid');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addSlot returns validation error when slotDate is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.addSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
mockPatch.mockResolvedValue({ data: { id: SLOT_UUID }, error: undefined });
|
||||
const result = await actions.updateSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||
expect.objectContaining({
|
||||
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } },
|
||||
body: { recipeId: RECIPE_UUID }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('updateSlot returns validation error when slotId is not a UUID', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', 'bad-id');
|
||||
formData.set('recipeId', RECIPE_UUID);
|
||||
const result = await actions.updateSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', PLAN_UUID);
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
mockDelete.mockResolvedValue({ error: undefined });
|
||||
const result = await actions.deleteSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(mockDelete).toHaveBeenCalledWith(
|
||||
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||
expect.objectContaining({
|
||||
params: { path: { planId: PLAN_UUID, slotId: SLOT_UUID } }
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteSlot returns validation error when planId is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('slotId', SLOT_UUID);
|
||||
const result = await actions.deleteSlot({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData }
|
||||
} as any);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Ungültige Eingabe.');
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
146
frontend/src/routes/(app)/planner/page.test.ts
Normal file
146
frontend/src/routes/(app)/planner/page.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001';
|
||||
// Use a past week so "today" is never in this range — selectedDay defaults to weekStart (Monday)
|
||||
const DATE = '2025-01-06'; // Monday, January 6 2025
|
||||
|
||||
const mockData = {
|
||||
weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] as any[] },
|
||||
varietyScore: null,
|
||||
weekStart: DATE,
|
||||
recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }],
|
||||
benutzer: { rolle: 'planer' }
|
||||
};
|
||||
|
||||
const mockDataWithSlot = {
|
||||
...mockData,
|
||||
weekPlan: {
|
||||
id: PLAN_ID,
|
||||
weekStart: DATE,
|
||||
status: 'draft',
|
||||
slots: [{ id: 'slot-1', slotDate: DATE, recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 } }]
|
||||
}
|
||||
};
|
||||
|
||||
const mockSuggestions = [
|
||||
{
|
||||
recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 },
|
||||
scoreDelta: 1.5,
|
||||
hasConflict: false
|
||||
}
|
||||
];
|
||||
|
||||
describe('+page.svelte — $effect suggestion fetch', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls fetch when picker opens with correct planId and date', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValueOnce({
|
||||
json: () => Promise.resolve({ suggestions: mockSuggestions })
|
||||
})
|
||||
);
|
||||
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`);
|
||||
expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`);
|
||||
});
|
||||
|
||||
it('shows suggestions in RecipePicker after fetch resolves', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValueOnce({
|
||||
json: () => Promise.resolve({ suggestions: mockSuggestions })
|
||||
})
|
||||
);
|
||||
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
|
||||
|
||||
expect(await screen.findByText('Lachsfilet')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('passes AbortSignal to fetch so inflight requests can be cancelled', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValueOnce({
|
||||
json: () => Promise.resolve({ suggestions: [] })
|
||||
})
|
||||
);
|
||||
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
const fetchOptions = (fetch as any).mock.calls[0][1];
|
||||
expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('+page.svelte — swap sheet suggestion fetch', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opening mobile swap sheet triggers fetch with planId and date', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
|
||||
|
||||
render(Page, { props: { data: mockDataWithSlot } });
|
||||
|
||||
// Open action sheet, then swap sheet
|
||||
await userEvent.click(screen.getByTestId('day-meal-card'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Gericht tauschen/i }));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`);
|
||||
expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('+page.svelte — remove meal', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('clicking Entfernen in MealActionSheet shows undo bar with recipe name', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
|
||||
|
||||
render(Page, { props: { data: mockDataWithSlot } });
|
||||
|
||||
await userEvent.click(screen.getByTestId('day-meal-card'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
|
||||
|
||||
const undoBar = screen.getByTestId('undo-bar');
|
||||
expect(undoBar).toBeTruthy();
|
||||
expect(within(undoBar).getByText(/Beef Bourguignon/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicking Rückgängig after remove hides the undo bar', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
|
||||
|
||||
render(Page, { props: { data: mockDataWithSlot } });
|
||||
|
||||
await userEvent.click(screen.getByTestId('day-meal-card'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i }));
|
||||
|
||||
expect(screen.queryByTestId('undo-bar')).toBeNull();
|
||||
});
|
||||
});
|
||||
91
frontend/src/routes/(app)/planner/server.test.ts
Normal file
91
frontend/src/routes/(app)/planner/server.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet })
|
||||
}));
|
||||
|
||||
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
|
||||
const DATE = '2026-04-09';
|
||||
|
||||
const mockSuggestions = [
|
||||
{ recipe: { id: 'r1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, scoreDelta: 0.0, hasConflict: true },
|
||||
{ recipe: { id: 'r2', name: 'Nudeln', effort: 'easy', cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true }
|
||||
];
|
||||
|
||||
describe('GET /planner — suggestions route handler', () => {
|
||||
let GET: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+server');
|
||||
GET = mod.GET;
|
||||
});
|
||||
|
||||
it('returns { suggestions: [] } when planId is missing', async () => {
|
||||
const url = new URL('http://localhost/planner?date=' + DATE);
|
||||
const response = await GET({ fetch: vi.fn(), url });
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ suggestions: [] });
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns { suggestions: [] } when date is missing', async () => {
|
||||
const url = new URL('http://localhost/planner?planId=' + PLAN_UUID);
|
||||
const response = await GET({ fetch: vi.fn(), url });
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ suggestions: [] });
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns sorted suggestions from backend on success', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: { suggestions: mockSuggestions }, error: undefined });
|
||||
|
||||
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
|
||||
const response = await GET({ fetch: vi.fn(), url });
|
||||
const body = await response.json();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
|
||||
params: { path: { id: PLAN_UUID }, query: { slotDate: DATE, topN: 100 } }
|
||||
}));
|
||||
expect(body.suggestions).toHaveLength(2);
|
||||
// sorted by scoreDelta desc: 0.0 before -1.5
|
||||
expect(body.suggestions[0].recipe.name).toBe('Lachsfilet');
|
||||
expect(body.suggestions[1].recipe.name).toBe('Nudeln');
|
||||
});
|
||||
|
||||
it('returns { suggestions: [] } when data is undefined (error response without data)', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
|
||||
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
|
||||
const response = await GET({ fetch: vi.fn(), url });
|
||||
const body = await response.json();
|
||||
|
||||
expect(body).toEqual({ suggestions: [] });
|
||||
});
|
||||
|
||||
it('returns { suggestions: [] } when backend throws (network error)', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
|
||||
const response = await GET({ fetch: vi.fn(), url });
|
||||
const body = await response.json();
|
||||
|
||||
expect(body).toEqual({ suggestions: [] });
|
||||
});
|
||||
|
||||
it('returns empty suggestions when backend returns empty array', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: { suggestions: [] }, error: undefined });
|
||||
|
||||
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
|
||||
const response = await GET({ fetch: vi.fn(), url });
|
||||
const body = await response.json();
|
||||
|
||||
expect(body).toEqual({ suggestions: [] });
|
||||
});
|
||||
});
|
||||
28
frontend/src/routes/(app)/planner/variety/+page.server.ts
Normal file
28
frontend/src/routes/(app)/planner/variety/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
|
||||
const api = apiClient(fetch);
|
||||
|
||||
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
|
||||
if (weekPlanError || !weekPlan?.id) {
|
||||
return { weekPlan: null, varietyScore: null, weekStart };
|
||||
}
|
||||
|
||||
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
|
||||
params: { path: { id: weekPlan.id } }
|
||||
});
|
||||
|
||||
return {
|
||||
weekPlan,
|
||||
varietyScore: varietyScore ?? null,
|
||||
weekStart
|
||||
};
|
||||
};
|
||||
233
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal file
233
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import VarietyScoreHero from '$lib/planner/VarietyScoreHero.svelte';
|
||||
import ScoreBreakdownList from '$lib/planner/ScoreBreakdownList.svelte';
|
||||
import VarietyWarningCards from '$lib/planner/VarietyWarningCards.svelte';
|
||||
import EffortBar from '$lib/planner/EffortBar.svelte';
|
||||
import { computeSubScores, computeWarnings } from '$lib/planner/variety';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let varietyScore = $derived(data.varietyScore);
|
||||
let weekStart = $derived(data.weekStart);
|
||||
|
||||
let score = $derived(varietyScore?.score ?? 0);
|
||||
|
||||
// Derive effort distribution from week plan slots
|
||||
let effortCounts = $derived.by(() => {
|
||||
const slots = weekPlan?.slots ?? [];
|
||||
let easy = 0, medium = 0, hard = 0;
|
||||
for (const slot of slots) {
|
||||
const effort = slot.recipe?.effort?.toLowerCase() ?? '';
|
||||
if (effort === 'easy' || effort === 'einfach') easy++;
|
||||
else if (effort === 'medium' || effort === 'mittel') medium++;
|
||||
else if (effort === 'hard' || effort === 'aufwändig') hard++;
|
||||
}
|
||||
return { easy, medium, hard };
|
||||
});
|
||||
|
||||
// Derive sub-scores from API data
|
||||
// TODO: replace with API-provided sub-scores once backend supports them.
|
||||
let subScores = $derived.by(() => computeSubScores({
|
||||
tagRepeats: varietyScore?.tagRepeats ?? [],
|
||||
ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [],
|
||||
...effortCounts
|
||||
}));
|
||||
|
||||
// Build warning list from API data
|
||||
let warnings = $derived.by(() => computeWarnings({
|
||||
tagRepeats: varietyScore?.tagRepeats ?? [],
|
||||
ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [],
|
||||
duplicatesInPlan: varietyScore?.duplicatesInPlan ?? []
|
||||
}));
|
||||
|
||||
// Protein grid: map protein tags to days of the week
|
||||
let proteinByDay = $derived.by(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const repeat of varietyScore?.tagRepeats ?? []) {
|
||||
if (repeat.tagType === 'protein') {
|
||||
for (const day of repeat.days ?? []) {
|
||||
map[day] = repeat.tagName ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Days of the week abbreviations for protein grid
|
||||
const weekDayAbbrs = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
const weekDayKeys = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Abwechslung überprüfen — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<!-- 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)]">
|
||||
Abwechslungs-Analyse
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-8 pt-5">
|
||||
{#if !varietyScore}
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte geplant. Plane zuerst einige Mahlzeiten.
|
||||
</p>
|
||||
<a
|
||||
href="/planner?week={weekStart}"
|
||||
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Zum Wochenplaner →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Big score -->
|
||||
<div class="mb-6">
|
||||
<VarietyScoreHero {score} />
|
||||
</div>
|
||||
|
||||
<!-- Sub-scores -->
|
||||
<div class="mb-6">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Bewertung im Detail
|
||||
</h2>
|
||||
<ScoreBreakdownList {subScores} />
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
{#if warnings.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Hinweise
|
||||
</h2>
|
||||
<VarietyWarningCards {warnings} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||
<!-- Topbar with breadcrumb -->
|
||||
<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}"
|
||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Planer
|
||||
</a>
|
||||
<span aria-hidden="true" class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/</span>
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
||||
Abwechslungs-Analyse
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar (224px) — nav placeholder for consistency with C1 -->
|
||||
<aside class="hidden w-[224px] flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)] xl:block">
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-8 py-6">
|
||||
{#if !varietyScore}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte geplant.
|
||||
</p>
|
||||
<a
|
||||
href="/planner?week={weekStart}"
|
||||
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Zum Wochenplaner →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Top section: 2 columns -->
|
||||
<div class="flex gap-8">
|
||||
<!-- Left: score + sub-scores -->
|
||||
<div class="flex-1">
|
||||
<VarietyScoreHero {score} />
|
||||
<div class="mt-6">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Bewertung im Detail
|
||||
</h2>
|
||||
<ScoreBreakdownList {subScores} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right (320px): protein grid + effort bar -->
|
||||
<div class="w-[320px] flex-shrink-0 space-y-6">
|
||||
<!-- Protein grid -->
|
||||
<div>
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Protein-Verteilung
|
||||
</h2>
|
||||
<div class="grid grid-cols-7 gap-[6px]">
|
||||
{#each weekDayAbbrs as abbr, i (weekDayKeys[i])}
|
||||
{@const key = weekDayKeys[i]}
|
||||
{@const protein = proteinByDay[key]}
|
||||
{@const isRepeated = protein && Object.values(proteinByDay).filter((p) => p === protein).length > 1}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="font-[var(--font-sans)] text-[10px] text-[var(--color-text-muted)]">{abbr}</span>
|
||||
<div
|
||||
data-testid="protein-cell"
|
||||
data-protein={protein ?? 'none'}
|
||||
class="flex h-[44px] w-full items-center justify-center rounded-[var(--radius-sm)] text-[10px] font-medium
|
||||
{protein
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
|
||||
: 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]'}
|
||||
{isRepeated ? 'ring-2 ring-[var(--yellow)]' : ''}"
|
||||
>
|
||||
{protein ? protein.split(' ')[0].slice(0, 3).toUpperCase() : '—'}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Effort bar -->
|
||||
<div>
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Aufwandsverteilung
|
||||
</h2>
|
||||
{#if (effortCounts.easy + effortCounts.medium + effortCounts.hard) > 0}
|
||||
<EffortBar
|
||||
easy={effortCounts.easy}
|
||||
medium={effortCounts.medium}
|
||||
hard={effortCounts.hard}
|
||||
/>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte geplant.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: warnings, full width -->
|
||||
{#if warnings.length > 0}
|
||||
<div class="mt-8 space-y-3">
|
||||
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Hinweise
|
||||
</h2>
|
||||
<VarietyWarningCards {warnings} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
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 mockVarietyScore = {
|
||||
score: 8.2,
|
||||
tagRepeats: [
|
||||
{ tagName: 'Chicken', tagType: 'protein', days: ['MON', 'WED'] }
|
||||
],
|
||||
ingredientOverlaps: [
|
||||
{ ingredientName: 'Tomaten', days: ['MON', 'TUE', 'WED'] }
|
||||
],
|
||||
recentRepeats: ['Pasta Bolognese'],
|
||||
duplicatesInPlan: ['Hühnchen Curry']
|
||||
};
|
||||
|
||||
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: 20 } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } },
|
||||
{ id: 's3', slotDate: '2026-04-01', recipe: { id: 'r3', name: 'Steak', effort: 'Hard', cookTimeMin: 60 } }
|
||||
]
|
||||
};
|
||||
|
||||
describe('variety page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
it('fetches week plan and variety score', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.anything());
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/variety-score', expect.objectContaining({
|
||||
params: { path: { id: 'plan-1' } }
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns varietyScore and weekPlan in result', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
|
||||
expect(result.varietyScore?.score).toBe(8.2);
|
||||
expect(result.weekPlan?.id).toBe('plan-1');
|
||||
});
|
||||
|
||||
it('returns weekStart from URL param', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
expect(result.weekStart).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns null data when week plan not found', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null varietyScore when score endpoint fails', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
expect(result.weekPlan?.id).toBe('plan-1');
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
|
||||
it('uses current week when no week param provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
// weekStart should be a valid YYYY-MM-DD
|
||||
expect(result.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
36
frontend/src/routes/(app)/recipes/+page.server.ts
Normal file
36
frontend/src/routes/(app)/recipes/+page.server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
import { addSlotAction, updateSlotAction, deleteSlotAction } from '$lib/server/slotActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const api = apiClient(fetch);
|
||||
const weekStart = getWeekStart(new Date());
|
||||
|
||||
const [recipesResult, weekPlanResult] = await Promise.all([
|
||||
api.GET('/v1/recipes', {}),
|
||||
api.GET('/v1/week-plans', { params: { query: { weekStart } } })
|
||||
]);
|
||||
|
||||
const recipes =
|
||||
recipesResult.error || !recipesResult.data?.data
|
||||
? []
|
||||
: recipesResult.data.data.map((r) => ({
|
||||
id: r.id!,
|
||||
name: r.name!,
|
||||
cookTimeMin: r.cookTimeMin,
|
||||
effort: r.effort,
|
||||
heroImageUrl: r.heroImageUrl
|
||||
}));
|
||||
|
||||
const activePlan =
|
||||
weekPlanResult.error || !weekPlanResult.data?.id ? null : weekPlanResult.data;
|
||||
|
||||
return { recipes, activePlan };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addSlot: addSlotAction,
|
||||
updateSlot: updateSlotAction,
|
||||
deleteSlot: deleteSlotAction
|
||||
};
|
||||
@@ -1 +1,224 @@
|
||||
<h1 class="text-2xl font-medium p-6">Rezepte</h1>
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
|
||||
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
|
||||
import type { RecipeSummary } from '$lib/recipes/types';
|
||||
import DayPicker from '$lib/planner/DayPicker.svelte';
|
||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||
|
||||
let { data, form = null }: { data: { recipes: RecipeSummary[]; activePlan: any }; form?: any } =
|
||||
$props();
|
||||
|
||||
// ── Search / filter ──────────────────────────────────────────────────────
|
||||
let searchQuery = $state('');
|
||||
let activeFilter = $state('Alle');
|
||||
|
||||
const effortMap: Record<string, string> = {
|
||||
Leicht: 'easy',
|
||||
Mittel: 'medium',
|
||||
Schwer: 'hard'
|
||||
};
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
data.recipes
|
||||
.filter((r) => {
|
||||
if (activeFilter === 'Alle') return true;
|
||||
return r.effort === effortMap[activeFilter];
|
||||
})
|
||||
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
// ── Today (computed once at module level) ─────────────────────────────────
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// ── DayPicker / BottomSheet state ─────────────────────────────────────────
|
||||
let pickerOpen = $state(false);
|
||||
let pickerRecipeId = $state('');
|
||||
let pickerRecipeName = $state('');
|
||||
let pickerPlan = $state<any>(null);
|
||||
let pickerWeekStart = $state('');
|
||||
|
||||
// ── Undo bar state ────────────────────────────────────────────────────────
|
||||
let undoVisible = $state(false);
|
||||
let undoMessage = $state('');
|
||||
let undoPlanId = $state('');
|
||||
let undoSlotId = $state('');
|
||||
|
||||
// ── Hidden form field state ───────────────────────────────────────────────
|
||||
let addPlanId = $state('');
|
||||
let addSlotDate = $state('');
|
||||
let addRecipeId = $state('');
|
||||
let updPlanId = $state('');
|
||||
let updSlotId = $state('');
|
||||
let updRecipeId = $state('');
|
||||
|
||||
// ── Form element refs ─────────────────────────────────────────────────────
|
||||
let addSlotFormEl: HTMLFormElement;
|
||||
let updateSlotFormEl: HTMLFormElement;
|
||||
let deleteSlotFormEl: HTMLFormElement;
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
function openDayPicker(recipeId: string, recipeName: string) {
|
||||
if (!data.activePlan) return;
|
||||
pickerRecipeId = recipeId;
|
||||
pickerRecipeName = recipeName;
|
||||
pickerPlan = data.activePlan;
|
||||
pickerWeekStart = data.activePlan.weekStart;
|
||||
pickerOpen = true;
|
||||
}
|
||||
|
||||
async function handleWeekChange(newWeekStart: string) {
|
||||
const res = await fetch(`/recipes?week=${newWeekStart}`);
|
||||
const { plan } = await res.json();
|
||||
pickerPlan = plan;
|
||||
pickerWeekStart = newWeekStart;
|
||||
}
|
||||
|
||||
async function handleDayPickerConfirm({ date, slotId }: { date: string; slotId: string | null }) {
|
||||
pickerOpen = false;
|
||||
|
||||
if (slotId) {
|
||||
// Replace existing slot
|
||||
updPlanId = pickerPlan?.id ?? '';
|
||||
updSlotId = slotId;
|
||||
updRecipeId = pickerRecipeId;
|
||||
await tick();
|
||||
updateSlotFormEl.requestSubmit();
|
||||
} else {
|
||||
// Add to empty slot
|
||||
addPlanId = pickerPlan?.id ?? '';
|
||||
addSlotDate = date;
|
||||
addRecipeId = pickerRecipeId;
|
||||
await tick();
|
||||
addSlotFormEl.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
undoVisible = false;
|
||||
deleteSlotFormEl.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rezepte — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">
|
||||
Rezepte
|
||||
</h1>
|
||||
<a
|
||||
href="/recipes/new"
|
||||
class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white"
|
||||
>
|
||||
Rezept hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<input type="search" placeholder="Suchen…" class="input" bind:value={searchQuery} />
|
||||
|
||||
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
|
||||
|
||||
<RecipeGrid
|
||||
recipes={filteredRecipes}
|
||||
onplan={data.activePlan ? openDayPicker : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)} height="55vh">
|
||||
{#if pickerPlan}
|
||||
<DayPicker
|
||||
recipeName={pickerRecipeName}
|
||||
recipeId={pickerRecipeId}
|
||||
planId={pickerPlan?.id ?? ''}
|
||||
weekStart={pickerWeekStart}
|
||||
{today}
|
||||
slots={pickerPlan?.slots ?? []}
|
||||
onconfirm={handleDayPickerConfirm}
|
||||
onweekchange={handleWeekChange}
|
||||
/>
|
||||
{/if}
|
||||
</BottomSheet>
|
||||
|
||||
<UndoBar
|
||||
visible={undoVisible}
|
||||
message={undoMessage}
|
||||
onundo={handleUndo}
|
||||
ondismiss={() => (undoVisible = false)}
|
||||
/>
|
||||
|
||||
<!-- Hidden forms for slot mutations -->
|
||||
<form
|
||||
bind:this={addSlotFormEl}
|
||||
method="POST"
|
||||
action="?/addSlot"
|
||||
class="hidden"
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', addPlanId);
|
||||
formData.set('slotDate', addSlotDate);
|
||||
formData.set('recipeId', addRecipeId);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') {
|
||||
undoPlanId = addPlanId;
|
||||
undoSlotId = (result.data as any)?.slot?.id ?? '';
|
||||
undoMessage = `${pickerRecipeName} hinzugefügt`;
|
||||
undoVisible = true;
|
||||
await invalidateAll();
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={addPlanId} />
|
||||
<input type="hidden" name="slotDate" value={addSlotDate} />
|
||||
<input type="hidden" name="recipeId" value={addRecipeId} />
|
||||
</form>
|
||||
|
||||
<form
|
||||
bind:this={updateSlotFormEl}
|
||||
method="POST"
|
||||
action="?/updateSlot"
|
||||
class="hidden"
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', updPlanId);
|
||||
formData.set('slotId', updSlotId);
|
||||
formData.set('recipeId', updRecipeId);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') {
|
||||
undoPlanId = updPlanId;
|
||||
undoSlotId = (result.data as any)?.slot?.id ?? '';
|
||||
undoMessage = `${pickerRecipeName} hinzugefügt`;
|
||||
undoVisible = true;
|
||||
await invalidateAll();
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={updPlanId} />
|
||||
<input type="hidden" name="slotId" value={updSlotId} />
|
||||
<input type="hidden" name="recipeId" value={updRecipeId} />
|
||||
</form>
|
||||
|
||||
<form
|
||||
bind:this={deleteSlotFormEl}
|
||||
method="POST"
|
||||
action="?/deleteSlot"
|
||||
class="hidden"
|
||||
use:enhance={({ formData }) => {
|
||||
formData.set('planId', undoPlanId);
|
||||
formData.set('slotId', undoSlotId);
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="planId" value={undoPlanId} />
|
||||
<input type="hidden" name="slotId" value={undoSlotId} />
|
||||
</form>
|
||||
|
||||
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
// GET /recipes?week=YYYY-MM-DD — returns week plan for DayPicker week navigation
|
||||
export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||
const weekStart = url.searchParams.get('week');
|
||||
if (!weekStart) return json({ plan: null });
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
|
||||
if (error || !data?.id) return json({ plan: null });
|
||||
return json({ plan: data });
|
||||
};
|
||||
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error: apiError } = await api.GET('/v1/recipes/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
if (apiError || !data) {
|
||||
error(404, 'Recipe not found');
|
||||
}
|
||||
|
||||
return {
|
||||
recipe: {
|
||||
id: data.id!,
|
||||
name: data.name!,
|
||||
serves: data.serves,
|
||||
cookTimeMin: data.cookTimeMin,
|
||||
effort: data.effort,
|
||||
heroImageUrl: data.heroImageUrl,
|
||||
ingredients: (data.ingredients ?? []).map((ing) => ({
|
||||
ingredientId: ing.ingredientId,
|
||||
name: ing.name,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
sortOrder: ing.sortOrder
|
||||
})),
|
||||
steps: (data.steps ?? []).map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
instruction: s.instruction
|
||||
})),
|
||||
tags: (data.tags ?? []).map((t) => ({
|
||||
id: t.id!,
|
||||
name: t.name!,
|
||||
tagType: t.tagType
|
||||
}))
|
||||
}
|
||||
};
|
||||
};
|
||||
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import RecipeHero from '$lib/recipes/RecipeHero.svelte';
|
||||
import IngredientList from '$lib/recipes/IngredientList.svelte';
|
||||
import StepList from '$lib/recipes/StepList.svelte';
|
||||
import type { RecipeDetail } from '$lib/recipes/types';
|
||||
|
||||
let { data }: { data: { recipe: RecipeDetail } } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.recipe.name} — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="hidden md:flex items-center justify-end px-[24px] py-[12px] border-b border-[var(--color-border)]">
|
||||
<a
|
||||
href="/recipes/{data.recipe.id}/edit"
|
||||
class="border border-[var(--color-border)] text-[var(--color-text)] text-[13px] font-medium font-sans tracking-[0.04em] rounded-[var(--radius-md)] px-[16px] py-[8px]"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<RecipeHero recipe={data.recipe} />
|
||||
|
||||
<div class="md:flex">
|
||||
<div class="md:flex-1 md:border-r md:border-[var(--color-border)] p-[24px]">
|
||||
<IngredientList ingredients={data.recipe.ingredients} />
|
||||
</div>
|
||||
<div class="md:flex-1 p-[24px]">
|
||||
<StepList steps={data.recipe.steps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user