Compare commits
170 Commits
feat/issue
...
16e1539ac0
| Author | SHA1 | Date | |
|---|---|---|---|
| 16e1539ac0 | |||
| e5cdce164a | |||
| 73b4fb84e7 | |||
| 932155c559 | |||
| a5bb5d45a3 | |||
| b2a798d90e | |||
| 23c821937f | |||
| 9df6d6f0c6 | |||
| ebaf42d83d | |||
| 56e6143fd2 | |||
| ed769b18a4 | |||
| f11cca534f | |||
| 822b34cd14 | |||
| 46f2ec45a3 | |||
| 90cff0c4d2 | |||
| b1eb9ed964 | |||
| 44b3f06474 | |||
| dbc78a1883 | |||
| 30ba53099c | |||
| 520dae5adf | |||
| f139dce82c | |||
| 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 | |||
| 116e400a91 | |||
| 6dd0b7ac93 | |||
| 49ed75a989 | |||
| 813ddf8214 | |||
| 7359eba946 | |||
| 16162d80f4 | |||
| 148f6a7b5b | |||
| f4503b0220 | |||
| f4648cc382 | |||
| 081b8dcaf0 | |||
| f33302e012 | |||
| 06bf567b90 | |||
| 1de9dfc314 | |||
| 77cdccb26c | |||
| 1611ddabf6 | |||
| f55d938b32 | |||
| cb921b3c0f | |||
| 8686f9eb9f | |||
| f7a239655a | |||
| 539ca5d231 | |||
| 0a9e8032cf | |||
| f84a647b8d | |||
| e17e8d4630 | |||
| 482597bb6a | |||
| 387d0705a4 | |||
| ab66269131 | |||
| 59366b6e9c | |||
| 4549e9a7fd | |||
| b6ad64ea53 | |||
| 7e97d2dc58 | |||
| d008a17735 | |||
| 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 |
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -31,3 +31,6 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Local dev config (may contain secrets / local DB credentials) ###
|
||||
src/main/resources/application-dev.yml
|
||||
|
||||
@@ -55,6 +55,16 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.coobird</groupId>
|
||||
<artifactId>thumbnailator</artifactId>
|
||||
<version>0.4.21</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.twelvemonkeys.imageio</groupId>
|
||||
<artifactId>imageio-webp</artifactId>
|
||||
<version>3.13.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import net.coobird.thumbnailator.Thumbnails;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class ImageCompressor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImageCompressor.class);
|
||||
|
||||
private static final int PREVIEW_WIDTH = 400;
|
||||
private static final double PREVIEW_QUALITY = 0.6;
|
||||
private static final String DATA_URI_PREFIX = "data:image/";
|
||||
private static final String BASE64_MARKER = ";base64,";
|
||||
private static final String OUTPUT_PREFIX = "data:image/jpeg;base64,";
|
||||
|
||||
public String compressToPreview(String dataUri) {
|
||||
if (dataUri == null || dataUri.isBlank()) return null;
|
||||
if (!dataUri.startsWith(DATA_URI_PREFIX)) return null;
|
||||
|
||||
int markerIdx = dataUri.indexOf(BASE64_MARKER);
|
||||
if (markerIdx < 0) return null;
|
||||
|
||||
byte[] imageBytes;
|
||||
try {
|
||||
imageBytes = Base64.getDecoder().decode(dataUri.substring(markerIdx + BASE64_MARKER.length()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
|
||||
if (original == null) {
|
||||
log.warn("ImageIO could not decode image — unsupported format (data URI prefix: {})",
|
||||
dataUri.substring(0, Math.min(dataUri.indexOf(',') + 1, 40)));
|
||||
return null;
|
||||
}
|
||||
|
||||
int targetWidth = Math.min(original.getWidth(), PREVIEW_WIDTH);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
Thumbnails.of(original)
|
||||
.width(targetWidth)
|
||||
.outputFormat("jpeg")
|
||||
.outputQuality(PREVIEW_QUALITY)
|
||||
.toOutputStream(bos);
|
||||
return OUTPUT_PREFIX + Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to generate image preview", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ public class RecipeController {
|
||||
Principal principal,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) String effort,
|
||||
@RequestParam(required = false) Boolean isChildFriendly,
|
||||
@RequestParam(name = "cookTimeMin.lte", required = false) Integer cookTimeMaxMin,
|
||||
@RequestParam(required = false) String sort,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@@ -37,9 +36,9 @@ public class RecipeController {
|
||||
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
List<RecipeSummaryResponse> recipes = recipeService.listRecipes(
|
||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin, sort, limit, offset);
|
||||
householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
|
||||
long total = recipeService.countRecipes(
|
||||
householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
householdId, search, effort, cookTimeMaxMin);
|
||||
|
||||
var pagination = new ApiResponse.Pagination(total, limit, offset, offset + limit < total);
|
||||
var meta = new ApiResponse.Meta(pagination);
|
||||
|
||||
@@ -18,13 +18,12 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
|
||||
@Query("""
|
||||
SELECT new com.recipeapp.recipe.dto.RecipeSummaryResponse(
|
||||
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.isChildFriendly, r.heroImageUrl)
|
||||
r.id, r.name, r.serves, r.cookTimeMin, r.effort, r.heroImagePreview)
|
||||
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 (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
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 (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
ORDER BY r.createdAt DESC
|
||||
""")
|
||||
@@ -32,7 +31,6 @@ public interface RecipeRepository extends JpaRepository<Recipe, UUID> {
|
||||
@Param("householdId") UUID householdId,
|
||||
@Param("search") String search,
|
||||
@Param("effort") String effort,
|
||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin,
|
||||
@Param("sort") String sort,
|
||||
@Param("limit") int limit,
|
||||
@@ -43,15 +41,13 @@ 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 (:isChildFriendly IS NULL OR r.isChildFriendly = :isChildFriendly)
|
||||
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 (:cookTimeMaxMin IS NULL OR r.cookTimeMin <= :cookTimeMaxMin)
|
||||
""")
|
||||
long countFiltered(
|
||||
@Param("householdId") UUID householdId,
|
||||
@Param("search") String search,
|
||||
@Param("effort") String effort,
|
||||
@Param("isChildFriendly") Boolean isChildFriendly,
|
||||
@Param("cookTimeMaxMin") Integer cookTimeMaxMin);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.recipeapp.recipe;
|
||||
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.recipe.dto.*;
|
||||
@@ -22,31 +23,31 @@ public class RecipeService {
|
||||
private final TagRepository tagRepository;
|
||||
private final IngredientCategoryRepository ingredientCategoryRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final ImageCompressor imageCompressor;
|
||||
|
||||
public RecipeService(RecipeRepository recipeRepository,
|
||||
IngredientRepository ingredientRepository,
|
||||
TagRepository tagRepository,
|
||||
IngredientCategoryRepository ingredientCategoryRepository,
|
||||
HouseholdRepository householdRepository) {
|
||||
HouseholdRepository householdRepository,
|
||||
ImageCompressor imageCompressor) {
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.ingredientCategoryRepository = ingredientCategoryRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.imageCompressor = imageCompressor;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecipeSummaryResponse> listRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin,
|
||||
String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, isChildFriendly,
|
||||
cookTimeMaxMin, sort, limit, offset);
|
||||
Integer cookTimeMaxMin, String sort, int limit, int offset) {
|
||||
return recipeRepository.findFiltered(householdId, search, effort, cookTimeMaxMin, sort, limit, offset);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countRecipes(UUID householdId, String search, String effort,
|
||||
Boolean isChildFriendly, Integer cookTimeMaxMin) {
|
||||
return recipeRepository.countFiltered(householdId, search, effort, isChildFriendly, cookTimeMaxMin);
|
||||
public long countRecipes(UUID householdId, String search, String effort, Integer cookTimeMaxMin) {
|
||||
return recipeRepository.countFiltered(householdId, search, effort, cookTimeMaxMin);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -60,9 +61,14 @@ public class RecipeService {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
Recipe recipe = new Recipe(household, request.name(), request.serves(),
|
||||
request.cookTimeMin(), request.effort(), request.isChildFriendly());
|
||||
validateHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
Recipe recipe = new Recipe(household, request.name(),
|
||||
request.serves() != null ? request.serves().shortValue() : 0,
|
||||
request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0,
|
||||
request.effort());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
|
||||
|
||||
addIngredients(recipe, household, request.ingredients());
|
||||
addSteps(recipe, request.steps());
|
||||
@@ -77,12 +83,14 @@ public class RecipeService {
|
||||
Recipe recipe = findRecipe(householdId, recipeId);
|
||||
Household household = recipe.getHousehold();
|
||||
|
||||
validateHeroImageUrl(request.heroImageUrl());
|
||||
|
||||
recipe.setName(request.name());
|
||||
recipe.setServes(request.serves());
|
||||
recipe.setCookTimeMin(request.cookTimeMin());
|
||||
recipe.setServes(request.serves() != null ? request.serves().shortValue() : 0);
|
||||
recipe.setCookTimeMin(request.cookTimeMin() != null ? request.cookTimeMin().shortValue() : 0);
|
||||
recipe.setEffort(request.effort());
|
||||
recipe.setChildFriendly(request.isChildFriendly());
|
||||
recipe.setHeroImageUrl(request.heroImageUrl());
|
||||
recipe.setHeroImagePreview(imageCompressor.compressToPreview(request.heroImageUrl()));
|
||||
|
||||
recipe.getIngredients().clear();
|
||||
recipe.getSteps().clear();
|
||||
@@ -180,6 +188,18 @@ public class RecipeService {
|
||||
return new IngredientCategoryResponse(category.getId(), category.getName());
|
||||
}
|
||||
|
||||
// ── Image validation ──
|
||||
|
||||
private static final java.util.regex.Pattern ALLOWED_IMAGE_PATTERN =
|
||||
java.util.regex.Pattern.compile("data:image/(jpeg|jpg|png|gif|webp);base64,.*");
|
||||
|
||||
private void validateHeroImageUrl(String heroImageUrl) {
|
||||
if (heroImageUrl == null || heroImageUrl.isBlank()) return;
|
||||
if (!ALLOWED_IMAGE_PATTERN.matcher(heroImageUrl).matches()) {
|
||||
throw new ValidationException("Ungültiger Bildtyp. Erlaubt sind: JPEG, PNG, GIF, WebP.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
private Recipe findRecipe(UUID householdId, UUID recipeId) {
|
||||
@@ -238,7 +258,7 @@ public class RecipeService {
|
||||
|
||||
return new RecipeDetailResponse(
|
||||
recipe.getId(), recipe.getName(), recipe.getServes(), recipe.getCookTimeMin(),
|
||||
recipe.getEffort(), recipe.isChildFriendly(), recipe.getHeroImageUrl(),
|
||||
recipe.getEffort(), recipe.getHeroImageUrl(),
|
||||
ingredients, steps, tags);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
public record RecipeCreateRequest(
|
||||
@NotBlank @Size(max = 200) String name,
|
||||
@Min(1) @Max(20) short serves,
|
||||
@Min(0) short cookTimeMin,
|
||||
Integer serves,
|
||||
Integer cookTimeMin,
|
||||
@NotBlank @Pattern(regexp = "easy|medium|hard") String effort,
|
||||
boolean isChildFriendly,
|
||||
@Size(max = 500) String heroImageUrl,
|
||||
@Size(max = 7_000_000) String heroImageUrl,
|
||||
@NotEmpty @Valid List<IngredientEntry> ingredients,
|
||||
@Valid List<StepEntry> steps,
|
||||
@NotEmpty List<UUID> tagIds
|
||||
|
||||
@@ -10,7 +10,6 @@ public record RecipeDetailResponse(
|
||||
short serves,
|
||||
short cookTimeMin,
|
||||
String effort,
|
||||
boolean isChildFriendly,
|
||||
String heroImageUrl,
|
||||
List<IngredientItem> ingredients,
|
||||
List<StepItem> steps,
|
||||
|
||||
@@ -8,6 +8,5 @@ public record RecipeSummaryResponse(
|
||||
short serves,
|
||||
short cookTimeMin,
|
||||
String effort,
|
||||
boolean isChildFriendly,
|
||||
String heroImageUrl
|
||||
String heroImagePreview
|
||||
) {}
|
||||
|
||||
@@ -33,12 +33,12 @@ public class Recipe {
|
||||
@Column(nullable = false, length = 10)
|
||||
private String effort;
|
||||
|
||||
@Column(name = "is_child_friendly", nullable = false)
|
||||
private boolean isChildFriendly;
|
||||
|
||||
@Column(name = "hero_image_url", length = 500)
|
||||
@Column(name = "hero_image_url", columnDefinition = "text")
|
||||
private String heroImageUrl;
|
||||
|
||||
@Column(name = "hero_image_preview", columnDefinition = "text")
|
||||
private String heroImagePreview;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private Instant deletedAt;
|
||||
|
||||
@@ -64,14 +64,12 @@ public class Recipe {
|
||||
|
||||
protected Recipe() {}
|
||||
|
||||
public Recipe(Household household, String name, short serves, short cookTimeMin,
|
||||
String effort, boolean isChildFriendly) {
|
||||
public Recipe(Household household, String name, short serves, short cookTimeMin, String effort) {
|
||||
this.household = household;
|
||||
this.name = name;
|
||||
this.serves = serves;
|
||||
this.cookTimeMin = cookTimeMin;
|
||||
this.effort = effort;
|
||||
this.isChildFriendly = isChildFriendly;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@@ -95,10 +93,10 @@ public class Recipe {
|
||||
public void setCookTimeMin(short cookTimeMin) { this.cookTimeMin = cookTimeMin; }
|
||||
public String getEffort() { return effort; }
|
||||
public void setEffort(String effort) { this.effort = effort; }
|
||||
public boolean isChildFriendly() { return isChildFriendly; }
|
||||
public void setChildFriendly(boolean childFriendly) { isChildFriendly = childFriendly; }
|
||||
public String getHeroImageUrl() { return heroImageUrl; }
|
||||
public void setHeroImageUrl(String heroImageUrl) { this.heroImageUrl = heroImageUrl; }
|
||||
public String getHeroImagePreview() { return heroImagePreview; }
|
||||
public void setHeroImagePreview(String heroImagePreview) { this.heroImagePreview = heroImagePreview; }
|
||||
public Instant getDeletedAt() { return deletedAt; }
|
||||
public void setDeletedAt(Instant deletedAt) { this.deletedAt = deletedAt; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
3
backend/src/main/resources/application-dev.yml
Normal file
3
backend/src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
spring:
|
||||
flyway:
|
||||
locations: classpath:db/migration,classpath:db/seed
|
||||
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
|
||||
@@ -19,5 +19,14 @@ spring:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
# NOTE: these limits only apply to multipart/form-data uploads.
|
||||
# Images sent as base64 inside a JSON body (Content-Type: application/json)
|
||||
# are NOT constrained here — the @Size(max=7_000_000) annotation on
|
||||
# RecipeCreateRequest.heroImageUrl enforces the limit for that path.
|
||||
max-file-size: 5MB
|
||||
max-request-size: 6MB
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE shopping_list
|
||||
ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now();
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recipe ALTER COLUMN hero_image_url TYPE text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recipe ADD COLUMN hero_image_preview text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recipe DROP COLUMN is_child_friendly;
|
||||
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;
|
||||
434
backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql
Normal file
434
backend/src/main/resources/db/seed/V101__dev_seed_recipes.sql
Normal file
@@ -0,0 +1,434 @@
|
||||
-- Dev seed: 11 HelloFresh vegetarian recipes (4 persons)
|
||||
-- Fixed UUIDs so the migration is idempotent and references are stable.
|
||||
-- Ingredients use dd000002-prefix, tags ee000001-prefix, recipes ff000002-prefix.
|
||||
|
||||
-- ─── Tags ────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO tag (id, household_id, name, tag_type) VALUES
|
||||
('ee000001-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Vegetarisch', 'dietary'),
|
||||
('ee000001-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Glutenfrei', 'dietary'),
|
||||
('ee000001-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Deutsch', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mediterran', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Asiatisch', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mexikanisch', 'cuisine'),
|
||||
('ee000001-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Käse', 'protein'),
|
||||
('ee000001-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hülsenfrüchte', 'protein'),
|
||||
('ee000001-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Eier', 'protein'),
|
||||
('ee000001-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Auflauf', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Nudeln', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Reis', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnell', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Ofengericht', 'other'),
|
||||
('ee000001-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchen', 'other')
|
||||
ON CONFLICT ON CONSTRAINT uq_tag_name DO NOTHING;
|
||||
|
||||
-- ─── Additional Ingredients ──────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO ingredient (id, household_id, name, is_staple, category_id) VALUES
|
||||
-- Gemüse
|
||||
('dd000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rucola', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kirschtomaten', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilischote', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gurke', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001', 'Radieschen', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Zwiebeln', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001', 'Rote Spitzpaprika', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gelbe Paprika', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
('dd000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001', 'Feldsalat', false, 'cc000001-0000-0000-0000-000000000001'),
|
||||
-- Obst
|
||||
('dd000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001', 'Avocado', false, 'cc000001-0000-0000-0000-000000000002'),
|
||||
('dd000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001', 'Äpfel', false, 'cc000001-0000-0000-0000-000000000002'),
|
||||
-- Milchprodukte & Eier
|
||||
('dd000002-0000-0000-0000-000000000012', 'bbbbbbbb-0000-0000-0000-000000000001', 'Hartkäse ital. Art', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000013', 'bbbbbbbb-0000-0000-0000-000000000001', 'Cheddar (gerieben)', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000014', 'bbbbbbbb-0000-0000-0000-000000000001', 'Frischkäse', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000015', 'bbbbbbbb-0000-0000-0000-000000000001', 'Joghurt', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000016', 'bbbbbbbb-0000-0000-0000-000000000001', 'Halloumi', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
('dd000002-0000-0000-0000-000000000017', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tex-Mex-Käsemischung', false, 'cc000001-0000-0000-0000-000000000004'),
|
||||
-- Getreide & Nudeln
|
||||
('dd000002-0000-0000-0000-000000000018', 'bbbbbbbb-0000-0000-0000-000000000001', 'Orzonudeln', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000019', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tortellini', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000020', 'bbbbbbbb-0000-0000-0000-000000000001', 'Jasminreis', true, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000021', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gnocchi (frisch)', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
('dd000002-0000-0000-0000-000000000022', 'bbbbbbbb-0000-0000-0000-000000000001', 'Fladenbrot', false, 'cc000001-0000-0000-0000-000000000005'),
|
||||
-- Hülsenfrüchte
|
||||
('dd000002-0000-0000-0000-000000000023', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schwarze Bohnen (Dose)', true, 'cc000001-0000-0000-0000-000000000006'),
|
||||
-- Konserven
|
||||
('dd000002-0000-0000-0000-000000000024', 'bbbbbbbb-0000-0000-0000-000000000001', 'Tomaten-Polpa', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
('dd000002-0000-0000-0000-000000000025', 'bbbbbbbb-0000-0000-0000-000000000001', 'Chilipolpa', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
('dd000002-0000-0000-0000-000000000026', 'bbbbbbbb-0000-0000-0000-000000000001', 'Getrocknete Tomaten', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
('dd000002-0000-0000-0000-000000000027', 'bbbbbbbb-0000-0000-0000-000000000001', 'Grüne Oliven', true, 'cc000001-0000-0000-0000-000000000007'),
|
||||
-- Gewürze & Kräuter
|
||||
('dd000002-0000-0000-0000-000000000028', 'bbbbbbbb-0000-0000-0000-000000000001', 'Petersilie (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000029', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikum (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000030', 'bbbbbbbb-0000-0000-0000-000000000001', 'Schnittlauch', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000031', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander (frisch)', false, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000032', 'bbbbbbbb-0000-0000-0000-000000000001', 'Scharfes Currypulver', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000033', 'bbbbbbbb-0000-0000-0000-000000000001', 'Kumin (gemahlen)', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000034', 'bbbbbbbb-0000-0000-0000-000000000001', 'Koriander & Kumin', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000035', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMexico', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000036', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung Kartoffelknaller', true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
('dd000002-0000-0000-0000-000000000037', 'bbbbbbbb-0000-0000-0000-000000000001', 'Gewürzmischung HelloMediterraneo',true, 'cc000001-0000-0000-0000-000000000008'),
|
||||
-- Tiefkühl
|
||||
('dd000002-0000-0000-0000-000000000038', 'bbbbbbbb-0000-0000-0000-000000000001', 'Flammkuchenteig', false, 'cc000001-0000-0000-0000-000000000013'),
|
||||
-- Saucen & Pasten
|
||||
('dd000002-0000-0000-0000-000000000039', 'bbbbbbbb-0000-0000-0000-000000000001', 'Balsamicocreme', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
('dd000002-0000-0000-0000-000000000040', 'bbbbbbbb-0000-0000-0000-000000000001', 'Basilikumpaste', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
('dd000002-0000-0000-0000-000000000041', 'bbbbbbbb-0000-0000-0000-000000000001', 'Mayonnaise', true, 'cc000001-0000-0000-0000-000000000010'),
|
||||
-- Nüsse & Samen
|
||||
('dd000002-0000-0000-0000-000000000042', 'bbbbbbbb-0000-0000-0000-000000000001', 'Haselnusskerne', true, 'cc000001-0000-0000-0000-000000000011')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ─── Recipes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO recipe (id, household_id, name, serves, cook_time_min, effort, is_child_friendly) VALUES
|
||||
('ff000002-0000-0000-0000-000000000001', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Scharfer Auflauf mit Orzonudeln', 4, 30, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000002', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Tortellini mit Ricotta-Füllung', 4, 25, 'easy', true),
|
||||
('ff000002-0000-0000-0000-000000000003', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Knuspriger Flammkuchen mit Mozzarella', 4, 35, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000004', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Fruchtiges Tomatenrisotto mit Zitrone', 4, 30, 'medium', false),
|
||||
('ff000002-0000-0000-0000-000000000005', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Karotten-Hafer-Puffer', 4, 40, 'medium', false),
|
||||
('ff000002-0000-0000-0000-000000000006', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Überbackene Penne mit getrockneten Tomaten', 4, 50, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000007', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Chili sin Carne', 4, 40, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000008', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Gebratene Gnocchi mit Ofenzucchini', 4, 35, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000009', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Pasta nach Art Caponata', 4, 45, 'easy', false),
|
||||
('ff000002-0000-0000-0000-000000000010', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Auflauf mit Halloumi und Aubergine', 4, 40, 'medium', false),
|
||||
('ff000002-0000-0000-0000-000000000011', 'bbbbbbbb-0000-0000-0000-000000000001',
|
||||
'Buntes Ofengemüse mit Halloumi', 4, 30, 'easy', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ─── Recipe Ingredients ──────────────────────────────────────────────────────
|
||||
-- V100 ingredients referenced by name via subquery (gen_random_uuid IDs).
|
||||
-- New dd000002 ingredients referenced by fixed UUID.
|
||||
|
||||
-- 01 Scharfer Auflauf mit Orzonudeln
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000024', 2, 'Dose', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Oliven (schwarz)'), 100, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000018', 300, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000013', 100, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 800, 'ml', 12);
|
||||
|
||||
-- 02 Tortellini mit Ricotta-Füllung
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000029', 5, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000028', 3, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000030', 2, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000019', 800, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Sonnenblumenkerne'), 10, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 4, 'EL', 10);
|
||||
|
||||
-- 03 Knuspriger Flammkuchen mit Mozzarella
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000028', 5, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000030', 5, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000014', 200, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000038', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 250, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000005', 200, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000015', 200, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 'dd000002-0000-0000-0000-000000000001', 200, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 3, 'EL', 12),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 2, 'EL', 13);
|
||||
|
||||
-- 04 Fruchtiges Tomatenrisotto mit Zitrone
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Risottoreis (Arborio)'), 600, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Mozzarella'), 2, 'Stück', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000040', 24, 'ml', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 'dd000002-0000-0000-0000-000000000002', 400, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Weißweinessig'), 1, 'EL', 12);
|
||||
|
||||
-- 05 Karotten-Hafer-Puffer
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Kartoffeln'), 1200,'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000036', 4, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000004', 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000011', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000015', 150, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000041', 4, 'EL', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Karotten'), 2, 'Stück', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Haferflocken'), 50, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000017', 200, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000032', 2, 'g', 12),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 'dd000002-0000-0000-0000-000000000009', 150, 'g', 13),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Eier'), 2, 'Stück', 14),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Balsamico-Essig'), 2, 'EL', 15);
|
||||
|
||||
-- 06 Überbackene Penne mit getrockneten Tomaten
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000030', 10, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000002', 300, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Senf (mittelscharf)'), 20, 'ml', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 'dd000002-0000-0000-0000-000000000013', 200, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Butter'), 5, 'g', 9);
|
||||
|
||||
-- 07 Chili sin Carne
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000020', 300, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 3, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000007', 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000008', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000023', 2, 'Dose', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000035', 8, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Gemüsebrühe'), 8, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000031', 20, 'g', 11),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000003', 2, 'Stück', 12),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Schmand'), 150, 'g', 13),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000028', 10, 'g', 14),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 15);
|
||||
|
||||
-- 08 Gebratene Gnocchi mit Ofenzucchini
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zucchini'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000037', 6, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000042', 40, 'g', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000014', 400, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000012', 40, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 'dd000002-0000-0000-0000-000000000021', 800, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 9);
|
||||
|
||||
-- 09 Pasta nach Art Caponata
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000026', 100, 'g', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000027', 120, 'g', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 1, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000025', 2, 'Dose', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Penne'), 500, 'g', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000012', 80, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 'dd000002-0000-0000-0000-000000000001', 75, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11);
|
||||
|
||||
-- 10 Auflauf mit Halloumi und Aubergine
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 4, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zwiebeln'), 4, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 6, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Aubergine'), 2, 'Stück', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000034', 4, 'g', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomatenmark'), 70, 'g', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000039', 24, 'ml', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000016', 400, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000029', 20, 'g', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 'dd000002-0000-0000-0000-000000000022', 1, 'Stück', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 5, 'EL', 11);
|
||||
|
||||
-- 11 Buntes Ofengemüse mit Halloumi
|
||||
INSERT INTO recipe_ingredient (id, recipe_id, ingredient_id, quantity, unit, sort_order) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Süßkartoffeln'), 2, 'Stück', 1),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000006', 2, 'Stück', 2),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Tomaten'), 2, 'Stück', 3),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000028', 20, 'g', 4),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000010', 2, 'Stück', 5),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Knoblauch'), 1, 'Stück', 6),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Zitronen'), 1, 'Stück', 7),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000033', 2, 'g', 8),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000003', 1, 'Stück', 9),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 'dd000002-0000-0000-0000-000000000016', 500, 'g', 10),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', (SELECT id FROM ingredient WHERE household_id = 'bbbbbbbb-0000-0000-0000-000000000001' AND name = 'Olivenöl (extra vergine)'), 2, 'EL', 11);
|
||||
|
||||
-- ─── Recipe Steps ─────────────────────────────────────────────────────────────
|
||||
|
||||
-- 01 Scharfer Auflauf mit Orzonudeln
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 1, 'Backofen auf 200 °C (Grillfunktion) vorheizen. Zwiebeln und Knoblauch abziehen und fein hacken. Aubergine in ca. 2 cm große Würfel schneiden. Heiße Gemüsebrühe vorbereiten. Chilischote halbieren, Kerne entfernen und in feine Streifen schneiden (Achtung: scharf!).'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und Knoblauch darin 2–3 Min. glasig andünsten. Orzonudeln und Aubergine zugeben und anbraten, bis das Öl vollständig aufgenommen ist. Brühe, Tomaten-Polpa und Chili zugeben, verrühren und ca. 10 Min. bei mittlerer Hitze köcheln lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 3, 'Zucchini längs halbieren und in 0,5 cm Scheiben schneiden. Oliven in Ringe schneiden. Zucchini und die Hälfte der Oliven zum Orzo geben und ca. 3 Min. mitkochen. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 4, 'Hartkäse fein reiben. Cheddar unter den Orzo heben und alles in eine Auflaufform füllen. Mit Hartkäse bestreuen und im Backofen 5–10 Min. gratinieren, bis der Käse goldbraun ist.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 5, 'Öl, Salz und Pfeffer in einer großen Schüssel vermengen. Rucola und restliche Oliven unterheben.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000001', 6, 'Orzoauflauf auf Teller verteilen und mit dem Rucola-Oliven-Salat servieren.');
|
||||
|
||||
-- 02 Tortellini mit Ricotta-Füllung
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Knoblauch abziehen. Zucchini in 0,5 cm dünne Scheiben schneiden. Kirschtomaten halbieren. Gemüse in eine große Schüssel geben, Knoblauch hinzupressen, mit Olivenöl, Salz und Pfeffer vermengen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 2, 'Gemüse auf einem mit Backpapier belegten Blech verteilen und 18–20 Min. backen, bis die Zucchini leicht bräunt und die Tomaten fast geschmolzen sind. Währenddessen Kräuter abzupfen, Basilikum und Petersilie fein hacken, Schnittlauch in Röllchen schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 3, 'Großen Topf mit gesalzenem Wasser zum Kochen bringen. Frischkäse mit den gehackten Kräutern verrühren, mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 4, 'Sonnenblumenkerne in einer kleinen Pfanne ohne Fett bei mittlerer Hitze goldbraun rösten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 5, 'Tortellini in den letzten 3–4 Min. der Gemüse-Backzeit in das kochende Wasser geben und al dente garen. Abgießen, zurück in den Topf geben. Gebackenes Gemüse und 4 EL Kräuterfrischkäse unterheben.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000002', 6, 'Tortellini auf Teller verteilen. Restlichen Kräuterfrischkäse als Kleckse darauf verteilen, mit Sonnenblumenkernen bestreuen und mit Basilikum dekorieren.');
|
||||
|
||||
-- 03 Knuspriger Flammkuchen mit Mozzarella
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Schnittlauch und Petersilie fein hacken und unter den Frischkäse heben. Flammkuchenteig auf einem mit Backpapier belegten Blech ausrollen und gleichmäßig mit dem Kräuterfrischkäse bestreichen (ca. 1 cm Rand frei lassen). Mit Salz und Pfeffer bestreuen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 2, 'Rote Zwiebeln abziehen, halbieren und in feine Streifen schneiden. Mozzarella in kleine Stücke zupfen. Flammkuchen mit Zwiebelstreifen belegen und Mozzarellastücke darauf verteilen. Auf der mittleren Schiene 13–15 Min. knusprig backen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 3, 'Gurke in lange, dünne Scheiben hobeln oder schneiden. Radieschen vierteln.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 4, 'Joghurt, Senf, Olivenöl, Weißweinessig, Salz und Pfeffer in einer großen Schüssel zu einem Dressing verrühren.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 5, 'Rucola, Gurkenstreifen und Radieschen in die Schüssel geben und unterheben. Bis zum Anrichten ziehen lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000003', 6, 'Flammkuchen in Stücke schneiden und auf Teller verteilen. Mit dem Salat servieren.');
|
||||
|
||||
-- 04 Fruchtiges Tomatenrisotto mit Zitrone
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 1, '1200 ml Wasser erhitzen. Karotten schälen und grob reiben. Zwiebeln fein würfeln. Knoblauch in dünne Scheiben schneiden. Hartkäse fein reiben. Zitronenschale abreiben, Zitronen halbieren und entsaften. Gemüsebrühe im heißen Wasser auflösen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 2, 'Öl in einem großen Topf erhitzen. Zwiebeln und Knoblauch darin 2–3 Min. glasig andünsten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 3, 'Risottoreis zugeben und unter Rühren erhitzen, bis das Öl vollständig aufgenommen ist. Karotten und ein Drittel der Brühe zugeben und gut verrühren. Restliche Brühe nach und nach einrühren. Insgesamt ca. 20 Min. köcheln lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 4, 'Mozzarella in mundgerechte Stücke schneiden. Mit Basilikumpaste, Olivenöl, Weißweinessig, Salz, Pfeffer und 1 Prise Zucker marinieren und ziehen lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 5, 'Kirschtomaten und Hartkäse in den Risotto einrühren. Mit Zitronenabrieb und Zitronensaft abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000004', 6, 'Risotto auf Teller verteilen und mit dem marinierten Basilikummozzarella toppen.');
|
||||
|
||||
-- 05 Karotten-Hafer-Puffer
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Kartoffeln ungeschält in Spalten (Wedges) schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Öl beträufeln, mit Gewürzmischung Kartoffelknaller, Salz und Pfeffer würzen. 20–25 Min. backen, bis die Wedges innen weich und außen knusprig sind.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 2, 'Gurke längs halbieren und in Halbmondscheiben schneiden. Äpfel entkernen und in dünne Halbmonde schneiden. Balsamicoessig, Öl und Senf zu einem Dressing verrühren, mit Salz und Pfeffer abschmecken. Gurke und Apfel unterheben und marinieren lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 3, 'Koriander fein hacken und mit Joghurt und Mayonnaise verrühren. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 4, 'Karotten schälen und grob raspeln. In einer großen Schüssel Karotten, Haferflocken, Eier, Tex-Mex-Käsemischung und Currypulver vermischen. Mit Salz und Pfeffer würzen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 5, 'Öl in einer großen Pfanne erhitzen. Karottenmischung mithilfe eines Esslöffels zu Puffern formen und leicht flach drücken. Von beiden Seiten je ca. 3 Min. goldbraun braten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000005', 6, 'Feldsalat unter den Apfel-Gurken-Salat heben. Auf Tellern anrichten, Karottenpuffer und Kartoffelwedges dazu platzieren. Mit Korianderdip beträufeln und genießen.');
|
||||
|
||||
-- 06 Überbackene Penne mit getrockneten Tomaten
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. Reichlich gesalzenes Wasser zum Kochen bringen. Penne 7–9 Min. bissfest garen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 2, 'Knoblauch abziehen und fein würfeln. Schnittlauch in Röllchen schneiden. Kirschtomaten halbieren. Getrocknete Tomaten grob zerkleinern.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 3, 'Frischkäse mit Knoblauch, Senf und dem Großteil des Schnittlauchs verrühren. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 4, 'Penne abgießen, dabei 100 ml Kochwasser auffangen. Penne zurück in den Topf geben. Frischkäsemischung und getrocknete Tomaten einrühren, bei Bedarf Kochwasser zugeben, bis eine cremige Konsistenz entsteht. Kirschtomaten unterheben.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 5, 'Penne-Mischung in eine mit Butter eingefettete Auflaufform füllen. Mit Cheddar bestreuen und im Backofen 6–7 Min. gratinieren.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000006', 6, 'Auflauf auf Teller verteilen und mit restlichem Schnittlauch bestreuen.');
|
||||
|
||||
-- 07 Chili sin Carne
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 1, 'Jasminreis mit 600 ml heißem Wasser in einem kleinen Topf aufkochen. Bei niedriger Hitze ca. 10 Min. köcheln lassen, vom Herd nehmen und abgedeckt 10 Min. quellen lassen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 2, 'Knoblauch abziehen und in feine Streifen schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Paprika halbieren, entkernen und in Streifen schneiden. Schwarze Bohnen abgießen und kalt abspülen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 3, 'Öl in einem großen Topf erhitzen. Zwiebeln und Paprika 2–3 Min. anbraten. Knoblauch und Gewürzmischung HelloMexico zugeben und 1 Min. mitbraten. Mit Salz und Pfeffer würzen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 4, 'Schwarze Bohnen, Chilipolpa, Gemüsebrühe und Balsamicocreme zugeben. Chili 25–30 Min. bei niedriger Hitze köcheln lassen, bis die Paprika weich und das Chili cremig ist. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 5, 'Koriander und Petersilie fein hacken. Chilischote entkernen und in Streifen schneiden (Achtung: scharf!). Avocado halbieren, Stein entfernen und in Streifen schneiden. Schmand mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000007', 6, 'Reis mit einer Gabel auflockern, Koriander unterheben und auf Teller verteilen. Chili daneben anrichten, mit Chili und Petersilie bestreuen. Mit Avocado und einem Klecks Schmand servieren.');
|
||||
|
||||
-- 08 Gebratene Gnocchi mit Ofenzucchini
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Zucchini in 0,5 cm dünne Scheiben schneiden. Auf einem mit Backpapier belegten Blech verteilen, mit Gewürzmischung HelloMediterraneo, Öl, Salz und Pfeffer würzen. Ca. 15 Min. backen, bis die Zucchini weich ist.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 2, 'Haselnusskerne in einer großen Pfanne ohne Fett bei mittlerer Hitze rösten, bis sie duften. Herausnehmen, abkühlen lassen und grob hacken. Pfanne beiseite stellen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 3, 'Knoblauch abziehen. Getrocknete Tomaten grob hacken und mit Frischkäse, Knoblauch und 200 ml Wasser in ein hohes Gefäß geben. Mit einem Pürierstab zu einer glatten Soße mixen. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 4, 'Öl in der Pfanne bei mittlerer Hitze erhitzen. Gnocchi darin 8–9 Min. anbraten, bis sie knusprig und leicht gebräunt sind.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 5, 'Soße zu den Gnocchi geben, alles vermengen und ca. 2 Min. einkochen lassen. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000008', 6, 'Gnocchi auf Teller verteilen, mit gebackener Zucchini, geriebenem Hartkäse und Haselnusskernen toppen.');
|
||||
|
||||
-- 09 Pasta nach Art Caponata
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 1, 'Reichlich gesalzenes Wasser für die Pasta aufkochen. Getrocknete Tomaten und grüne Oliven grob hacken (Öl der Oliven auffangen). Knoblauch abziehen und fein hacken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 2, 'Aubergine in 1–2 cm Würfel schneiden. Rote Zwiebeln halbieren und in Streifen schneiden. Olivenöl in einer großen Pfanne stark erhitzen. Aubergine 3–4 Min. scharf anbraten. Zwiebeln, getrocknete Tomaten, Oliven und Knoblauch zugeben und 2 Min. mitbraten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 3, 'Hitze reduzieren, Chilipolpa zugeben und alles 10–12 Min. köcheln lassen, bis die Soße eingedickt und das Gemüse weich ist. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 4, 'Penne ca. 10 Min. bissfest garen und abgießen. Zitrone heiß abwaschen, Schale abreiben und in Spalten schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 5, 'Penne zur Soße in die Pfanne geben und gut vermengen. Mit Zitronenabrieb und Zitronensaft abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000009', 6, 'Pasta in tiefen Tellern anrichten, mit geriebenem Hartkäse und Rucola bestreuen.');
|
||||
|
||||
-- 10 Auflauf mit Halloumi und Aubergine
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 1, 'Backofen auf 200 °C Ober-/Unterhitze (180 °C Umluft) vorheizen. 2 Knoblauchzehen mit dem Messerrücken andrücken und 15 Min. im Ofen rösten. Restlichen Knoblauch abziehen und fein hacken. Zwiebeln in Streifen schneiden. Tomaten und Auberginen in ca. 2 cm Würfel schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 2, 'Öl in einer großen Pfanne erhitzen. Zwiebeln und gehackten Knoblauch 3 Min. andünsten. Aubergine, Tomatenwürfel sowie Koriander & Kumin zugeben und kurz anbraten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 3, 'Gemüse mit 200 ml Wasser ablöschen. Tomatenmark und Balsamicocreme einrühren und ca. 10 Min. köcheln lassen. Mit Salz und Pfeffer abschmecken. Währenddessen Halloumi in 0,5 cm Scheiben schneiden und Basilikumblätter abzupfen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 4, 'Soße in eine große Auflaufform geben und Halloumischeiben darüber verteilen. Ca. 20 Min. im Ofen backen. Fladenbrot in 2 cm Scheiben schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 5, 'Geröstete Knoblauchzehen abziehen und fein hacken. Mit Olivenöl, Salz und Pfeffer verrühren. Knoblauchöl auf die Brotscheiben träufeln, auf ein Backblech legen und 5–10 Min. knusprig aufbacken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000010', 6, 'Auflauf 2 Min. unter dem Grill bräunen, bis der Halloumi goldbraun ist. Mit Basilikumblättern bestreuen und mit dem Knoblauchbrot servieren.');
|
||||
|
||||
-- 11 Buntes Ofengemüse mit Halloumi
|
||||
INSERT INTO recipe_step (id, recipe_id, step_number, instruction) VALUES
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 1, 'Backofen auf 220 °C Ober-/Unterhitze (200 °C Umluft) vorheizen. Süßkartoffeln schälen und in 2 cm Würfel schneiden. Rote Zwiebeln halbieren und in ca. 1 cm Spalten schneiden.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 2, 'Süßkartoffelwürfel und Zwiebelspalten auf einem mit Backpapier belegten Blech verteilen, mit Salz und Pfeffer würzen. Ca. 25 Min. backen, bis die Süßkartoffeln weich sind.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 3, 'Tomaten halbieren und in Spalten schneiden. Petersilie fein hacken. Avocado würfeln. Die Hälfte der Petersilie und die Avocadowürfel zu den Tomaten geben und beiseitestellen.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 4, 'Knoblauch und Chilischote fein hacken. Restliche Petersilie mit Kumin, Knoblauch, Chili (Achtung: scharf!), Zitronensaft und Olivenöl zu einem Chimichurri verrühren. Mit Salz und Pfeffer abschmecken.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 5, 'Halloumi in ca. 3 cm Würfel schneiden. In einer Pfanne mit etwas Öl bei mittlerer Hitze rundherum 3–4 Min. goldbraun braten.'),
|
||||
(gen_random_uuid(), 'ff000002-0000-0000-0000-000000000011', 6, 'Geröstetes Gemüse und Zwiebeln in die Schüssel mit Tomaten und Avocado geben, vorsichtig vermengen. Auf Teller verteilen, mit Halloumiwürfeln toppen und Petersilien-Chimichurri darüberträufeln.');
|
||||
|
||||
-- ─── Recipe Tags ──────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO recipe_tag (recipe_id, tag_id) VALUES
|
||||
-- 01 Scharfer Auflauf
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000001', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
|
||||
-- 02 Tortellini
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
|
||||
('ff000002-0000-0000-0000-000000000002', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
|
||||
-- 03 Flammkuchen
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000003', 'ee000001-0000-0000-0000-000000000015'), -- Flammkuchen
|
||||
-- 04 Tomatenrisotto
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000012'), -- Reis
|
||||
('ff000002-0000-0000-0000-000000000004', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
|
||||
-- 05 Karotten-Hafer-Puffer
|
||||
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000003'), -- Deutsch
|
||||
('ff000002-0000-0000-0000-000000000005', 'ee000001-0000-0000-0000-000000000009'), -- Eier
|
||||
-- 06 Überbackene Penne
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
|
||||
('ff000002-0000-0000-0000-000000000006', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
|
||||
-- 07 Chili sin Carne
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000006'), -- Mexikanisch
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000008'), -- Hülsenfrüchte
|
||||
('ff000002-0000-0000-0000-000000000007', 'ee000001-0000-0000-0000-000000000012'), -- Reis
|
||||
-- 08 Gnocchi
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000008', 'ee000001-0000-0000-0000-000000000013'), -- Schnell
|
||||
-- 09 Pasta Caponata
|
||||
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000009', 'ee000001-0000-0000-0000-000000000011'), -- Nudeln
|
||||
-- 10 Auflauf Halloumi
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000010', 'ee000001-0000-0000-0000-000000000010'), -- Auflauf
|
||||
-- 11 Buntes Ofengemüse
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000001'), -- Vegetarisch
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000002'), -- Glutenfrei
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000004'), -- Mediterran
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000007'), -- Käse
|
||||
('ff000002-0000-0000-0000-000000000011', 'ee000001-0000-0000-0000-000000000014') -- Ofengericht
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class PlanningServiceTest {
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household, String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class SuggestionsTest {
|
||||
}
|
||||
|
||||
private Recipe createRecipe(String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -69,7 +69,7 @@ class VarietyScoreTest {
|
||||
}
|
||||
|
||||
private Recipe createRecipe(String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 30, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.recipeapp.recipe;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class ImageCompressorTest {
|
||||
|
||||
private final ImageCompressor compressor = new ImageCompressor();
|
||||
|
||||
@Test
|
||||
void compressToPreview_returnsJpegDataUri() throws Exception {
|
||||
String dataUri = makePngDataUri(800, 600);
|
||||
String result = compressor.compressToPreview(dataUri);
|
||||
assertThat(result).startsWith("data:image/jpeg;base64,");
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_outputIsDecodableJpeg() throws Exception {
|
||||
String dataUri = makePngDataUri(800, 600);
|
||||
String result = compressor.compressToPreview(dataUri);
|
||||
|
||||
String base64 = result.substring("data:image/jpeg;base64,".length());
|
||||
byte[] bytes = Base64.getDecoder().decode(base64);
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
|
||||
assertThat(img).isNotNull();
|
||||
assertThat(img.getWidth()).isLessThanOrEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_preservesAspectRatio() throws Exception {
|
||||
String dataUri = makePngDataUri(800, 400); // 2:1 ratio
|
||||
String result = compressor.compressToPreview(dataUri);
|
||||
|
||||
String base64 = result.substring("data:image/jpeg;base64,".length());
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
|
||||
|
||||
assertThat(img).isNotNull();
|
||||
double ratio = (double) img.getWidth() / img.getHeight();
|
||||
assertThat(ratio).isCloseTo(2.0, within(0.1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_doesNotUpscaleSmallImages() throws Exception {
|
||||
String dataUri = makePngDataUri(200, 150); // smaller than 400px
|
||||
String result = compressor.compressToPreview(dataUri);
|
||||
|
||||
String base64 = result.substring("data:image/jpeg;base64,".length());
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
|
||||
|
||||
assertThat(img).isNotNull();
|
||||
assertThat(img.getWidth()).isLessThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_returnsNullForNull() {
|
||||
assertThat(compressor.compressToPreview(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_returnsNullForBlankString() {
|
||||
assertThat(compressor.compressToPreview(" ")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_returnsNullForNonDataUri() {
|
||||
assertThat(compressor.compressToPreview("https://example.com/image.jpg")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_returnsNullForInvalidBase64() {
|
||||
assertThat(compressor.compressToPreview("data:image/jpeg;base64,!!!not-valid!!!")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void compressToPreview_acceptsJpegInput() throws Exception {
|
||||
String dataUri = makeJpegDataUri(800, 600);
|
||||
String result = compressor.compressToPreview(dataUri);
|
||||
assertThat(result).startsWith("data:image/jpeg;base64,");
|
||||
String base64 = result.substring("data:image/jpeg;base64,".length());
|
||||
BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
|
||||
assertThat(img).isNotNull();
|
||||
assertThat(img.getWidth()).isLessThanOrEqualTo(400);
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
private String makePngDataUri(int width, int height) throws Exception {
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
// draw gradient so PNG and JPEG both have non-trivial content
|
||||
for (int x = 0; x < width; x++) {
|
||||
g.setColor(new Color(x * 255 / width, (x * 128 / width + height / 2) % 256, 128));
|
||||
g.drawLine(x, 0, x, height);
|
||||
}
|
||||
g.dispose();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", bos);
|
||||
return "data:image/png;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
|
||||
private String makeJpegDataUri(int width, int height) throws Exception {
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
java.awt.Graphics2D g = img.createGraphics();
|
||||
g.setColor(java.awt.Color.ORANGE);
|
||||
g.fillRect(0, 0, width, height);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "jpeg", bos);
|
||||
return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,13 @@ class RecipeControllerTest {
|
||||
@Test
|
||||
void listRecipesShouldReturn200WithPagination() throws Exception {
|
||||
var summary = new RecipeSummaryResponse(RECIPE_ID, "Spaghetti Bolognese",
|
||||
(short) 4, (short) 45, "medium", true, null);
|
||||
(short) 4, (short) 45, "medium", null);
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull(),
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(),
|
||||
isNull(), eq(20), eq(0)))
|
||||
.thenReturn(List.of(summary));
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull(), isNull()))
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), isNull(), isNull(), isNull()))
|
||||
.thenReturn(1L);
|
||||
|
||||
mockMvc.perform(get("/v1/recipes")
|
||||
@@ -69,17 +69,16 @@ class RecipeControllerTest {
|
||||
@Test
|
||||
void listRecipesWithFiltersShouldPassParams() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true),
|
||||
when(recipeService.listRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"),
|
||||
eq(30), eq("-cookTimeMin"), eq(10), eq(5)))
|
||||
.thenReturn(List.of());
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(true), eq(30)))
|
||||
when(recipeService.countRecipes(eq(HOUSEHOLD_ID), eq("pasta"), eq("easy"), eq(30)))
|
||||
.thenReturn(0L);
|
||||
|
||||
mockMvc.perform(get("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.param("search", "pasta")
|
||||
.param("effort", "easy")
|
||||
.param("isChildFriendly", "true")
|
||||
.param("cookTimeMin.lte", "30")
|
||||
.param("sort", "-cookTimeMin")
|
||||
.param("limit", "10")
|
||||
@@ -162,10 +161,50 @@ class RecipeControllerTest {
|
||||
verify(recipeService).deleteRecipe(HOUSEHOLD_ID, RECIPE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithOversizedHeroImageShouldReturn400() throws Exception {
|
||||
String heroImageUrl = "data:image/jpeg;base64," + "A".repeat(7_000_000);
|
||||
String body = "{\"name\":\"Test\",\"effort\":\"easy\",\"tagIds\":[\"" + UUID.randomUUID() + "\"]," +
|
||||
"\"ingredients\":[{\"quantity\":1,\"unit\":\"g\",\"newIngredientName\":\"x\",\"sortOrder\":0}]," +
|
||||
"\"heroImageUrl\":\"" + heroImageUrl + "\"}";
|
||||
|
||||
mockMvc.perform(post("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithEmptyIngredientsListShouldReturn400() throws Exception {
|
||||
var body = """
|
||||
{"name":"Test","effort":"easy","tagIds":["%s"],"ingredients":[]}
|
||||
""".formatted(UUID.randomUUID());
|
||||
|
||||
mockMvc.perform(post("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithCapitalisedEffortShouldReturn400() throws Exception {
|
||||
var body = """
|
||||
{"name":"Test","effort":"Easy","tagIds":["%s"],"ingredients":[{"quantity":1,"unit":"g","newIngredientName":"x","sortOrder":0}]}
|
||||
""".formatted(UUID.randomUUID());
|
||||
|
||||
mockMvc.perform(post("/v1/recipes")
|
||||
.principal(() -> "sarah@example.com")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
private RecipeCreateRequest sampleCreateRequest() {
|
||||
var ingredientId = UUID.randomUUID();
|
||||
return new RecipeCreateRequest(
|
||||
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
"Spaghetti Bolognese", 4, 45, "medium", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredientId, null, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
|
||||
@@ -175,7 +214,7 @@ class RecipeControllerTest {
|
||||
private RecipeDetailResponse sampleDetail() {
|
||||
var catRef = new RecipeDetailResponse.CategoryRef(UUID.randomUUID(), "pasta");
|
||||
return new RecipeDetailResponse(
|
||||
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
RECIPE_ID, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", null,
|
||||
List.of(new RecipeDetailResponse.IngredientItem(
|
||||
UUID.randomUUID(), "spaghetti", catRef, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeDetailResponse.StepItem((short) 1, "Boil water.")),
|
||||
|
||||
@@ -27,6 +27,7 @@ class RecipeServiceTest {
|
||||
@Mock private TagRepository tagRepository;
|
||||
@Mock private IngredientCategoryRepository ingredientCategoryRepository;
|
||||
@Mock private HouseholdRepository householdRepository;
|
||||
@Mock private ImageCompressor imageCompressor;
|
||||
|
||||
@InjectMocks private RecipeService recipeService;
|
||||
|
||||
@@ -43,7 +44,7 @@ class RecipeServiceTest {
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household) {
|
||||
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium", true);
|
||||
var r = new Recipe(household, "Spaghetti Bolognese", (short) 4, (short) 45, "medium");
|
||||
try {
|
||||
var field = Recipe.class.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
@@ -126,7 +127,7 @@ class RecipeServiceTest {
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Spaghetti Bolognese", (short) 4, (short) 45, "medium", true, null,
|
||||
"Spaghetti Bolognese", 4, 45, "medium", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredient.getId(), null, new BigDecimal("400"), "g", (short) 1)),
|
||||
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Boil water.")),
|
||||
@@ -166,7 +167,7 @@ class RecipeServiceTest {
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Carbonara", (short) 2, (short) 30, "medium", false, null,
|
||||
"Carbonara", 2, 30, "medium", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
null, "pancetta", new BigDecimal("100"), "g", (short) 1)),
|
||||
List.of(),
|
||||
@@ -192,7 +193,7 @@ class RecipeServiceTest {
|
||||
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Chicken Rice", (short) 3, (short) 25, "easy", true, null,
|
||||
"Chicken Rice", 3, 25, "easy", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredient.getId(), null, new BigDecimal("300"), "g", (short) 1)),
|
||||
List.of(new RecipeCreateRequest.StepEntry((short) 1, "Cook rice.")),
|
||||
@@ -450,7 +451,7 @@ class RecipeServiceTest {
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.empty());
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", (short) 2, (short) 15, "easy", false, null,
|
||||
"Test", 2, 15, "easy", null,
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
|
||||
@@ -466,7 +467,7 @@ class RecipeServiceTest {
|
||||
when(ingredientRepository.findById(ingredientId)).thenReturn(Optional.empty());
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", (short) 2, (short) 15, "easy", false, null,
|
||||
"Test", 2, 15, "easy", null,
|
||||
List.of(new RecipeCreateRequest.IngredientEntry(
|
||||
ingredientId, null, new BigDecimal("100"), "g", (short) 1)),
|
||||
List.of(), List.of());
|
||||
@@ -491,7 +492,7 @@ class RecipeServiceTest {
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Simple", (short) 1, (short) 5, "easy", false, null,
|
||||
"Simple", 1, 5, "easy", null,
|
||||
null, null, null);
|
||||
|
||||
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
|
||||
@@ -518,13 +519,36 @@ class RecipeServiceTest {
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Updated", (short) 2, (short) 20, "easy", false, null,
|
||||
"Updated", 2, 20, "easy", null,
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatThrownBy(() -> recipeService.updateRecipe(HOUSEHOLD_ID, id, request))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithNullServesAndCookTimeShouldStoreZero() {
|
||||
var household = testHousehold();
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> {
|
||||
Recipe r = i.getArgument(0);
|
||||
try {
|
||||
var field = Recipe.class.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(r, UUID.randomUUID());
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
return r;
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest("Soup", null, null, "easy", null,
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
|
||||
|
||||
assertThat(result.serves()).isEqualTo((short) 0);
|
||||
assertThat(result.cookTimeMin()).isEqualTo((short) 0);
|
||||
}
|
||||
|
||||
// ── Tag/Category edge cases ──
|
||||
|
||||
@Test
|
||||
@@ -547,6 +571,33 @@ class RecipeServiceTest {
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithDisallowedImageTypeShouldThrowValidationException() {
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(testHousehold()));
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", null, null, "easy", "data:application/pdf;base64,abc",
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request))
|
||||
.isInstanceOf(com.recipeapp.common.ValidationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeWithAllowedImageTypeShouldNotThrow() {
|
||||
var household = testHousehold();
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
// "abc" is not valid base64 for a real image; ImageCompressor will return null for the
|
||||
// preview, but validateHeroImageUrl() should pass for a well-formed data URI prefix.
|
||||
var request = new RecipeCreateRequest(
|
||||
"Test", null, null, "easy", "data:image/jpeg;base64,abc",
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
assertThatNoException().isThrownBy(() -> recipeService.createRecipe(HOUSEHOLD_ID, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listTagsShouldReturnEmptyList() {
|
||||
when(tagRepository.findByHouseholdId(HOUSEHOLD_ID)).thenReturn(List.of());
|
||||
@@ -555,4 +606,30 @@ class RecipeServiceTest {
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRecipeShouldStoreNullPreviewWhenCompressorReturnsNull() {
|
||||
var household = testHousehold();
|
||||
when(householdRepository.findById(HOUSEHOLD_ID)).thenReturn(Optional.of(household));
|
||||
when(imageCompressor.compressToPreview(any())).thenReturn(null);
|
||||
when(recipeRepository.save(any(Recipe.class))).thenAnswer(i -> {
|
||||
Recipe r = i.getArgument(0);
|
||||
try {
|
||||
var field = Recipe.class.getDeclaredField("id");
|
||||
field.setAccessible(true);
|
||||
field.set(r, UUID.randomUUID());
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
return r;
|
||||
});
|
||||
|
||||
var request = new RecipeCreateRequest(
|
||||
"Soup", null, null, "easy", "data:image/jpeg;base64,abc",
|
||||
List.of(), List.of(), List.of());
|
||||
|
||||
RecipeDetailResponse result = recipeService.createRecipe(HOUSEHOLD_ID, request);
|
||||
|
||||
assertThat(result.id()).isNotNull();
|
||||
// verify the recipe was saved without a preview (compressor returned null)
|
||||
verify(recipeRepository).save(argThat(r -> r.getHeroImagePreview() == null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,7 +60,7 @@ class ShoppingServiceTest {
|
||||
}
|
||||
|
||||
private Recipe testRecipe(Household household, String name) {
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium", true);
|
||||
var r = new Recipe(household, name, (short) 4, (short) 45, "medium");
|
||||
setId(r, Recipe.class, UUID.randomUUID());
|
||||
return r;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
6
frontend/e2e/startseite.test.ts
Normal file
6
frontend/e2e/startseite.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('Startseite lädt korrekt', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Willkommen bei Mealprep' })).toBeVisible();
|
||||
});
|
||||
12
frontend/playwright.config.ts
Normal file
12
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173
|
||||
},
|
||||
testDir: 'e2e',
|
||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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
57
frontend/src/lib/api/schema.d.ts
vendored
57
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;
|
||||
@@ -536,7 +552,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
ingredients: components["schemas"]["IngredientEntry"][];
|
||||
steps?: components["schemas"]["StepEntry"][];
|
||||
@@ -571,7 +586,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
ingredients?: components["schemas"]["IngredientItem"][];
|
||||
steps?: components["schemas"]["StepItem"][];
|
||||
@@ -624,6 +638,11 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
recipeId: string;
|
||||
};
|
||||
RecipeRef: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
ShoppingListItemResponse: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
@@ -636,13 +655,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 +912,8 @@ export interface components {
|
||||
SuggestionItem: {
|
||||
recipe?: components["schemas"]["SlotRecipe"];
|
||||
/** Format: double */
|
||||
simulatedScore?: number;
|
||||
scoreDelta?: number;
|
||||
hasConflict?: boolean;
|
||||
};
|
||||
SuggestionResponse: {
|
||||
suggestions?: components["schemas"]["SuggestionItem"][];
|
||||
@@ -908,8 +932,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
isChildFriendly?: boolean;
|
||||
heroImageUrl?: string;
|
||||
heroImagePreview?: string;
|
||||
};
|
||||
ApiResponseListAdminUserResponse: {
|
||||
status?: string;
|
||||
@@ -1902,6 +1925,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?: {
|
||||
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
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();
|
||||
});
|
||||
});
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
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');
|
||||
});
|
||||
});
|
||||
51
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal file
51
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
interface WarningItem {
|
||||
dayShort: string;
|
||||
recipeName: string;
|
||||
slotId: number;
|
||||
}
|
||||
|
||||
interface ActionWarning {
|
||||
title: string;
|
||||
items: WarningItem[];
|
||||
}
|
||||
|
||||
let { warnings, weekStart }: { warnings: ActionWarning[]; weekStart: string } = $props();
|
||||
</script>
|
||||
|
||||
{#each warnings as warning (warning.title)}
|
||||
<div
|
||||
data-testid="warning-card"
|
||||
class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] overflow-hidden"
|
||||
>
|
||||
<!-- Header row -->
|
||||
<div class="px-4 py-2.5 border-b border-[var(--yellow-light)]">
|
||||
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
|
||||
{warning.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Item rows -->
|
||||
{#each warning.items as item (item.slotId)}
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-[var(--yellow-light)] last:border-b-0">
|
||||
<!-- Left: day label + recipe name -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--yellow-text)] w-6 flex-shrink-0">
|
||||
{item.dayShort}
|
||||
</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
||||
{item.recipeName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Right: swap link -->
|
||||
<a
|
||||
href="/planner?week={weekStart}&swap={item.slotId}"
|
||||
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] flex-shrink-0 hover:underline"
|
||||
>
|
||||
Tauschen →
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
46
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal file
46
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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',
|
||||
items: [
|
||||
{ dayShort: 'Mo', recipeName: 'Chicken Tikka', slotId: 1 },
|
||||
{ dayShort: 'Mi', recipeName: 'Chicken Curry', slotId: 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tomaten in 3 Gerichten',
|
||||
items: [
|
||||
{ dayShort: 'Mo', recipeName: 'Pasta Pomodoro', slotId: 1 },
|
||||
{ dayShort: 'Di', recipeName: 'Tomatensuppe', slotId: 2 },
|
||||
{ dayShort: 'Mi', recipeName: 'Pizza Margherita', slotId: 3 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
describe('VarietyWarningCards', () => {
|
||||
it('renders one card per warning', () => {
|
||||
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||
const cards = screen.getAllByTestId('warning-card');
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders warning titles', () => {
|
||||
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
|
||||
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders warning explanations', () => {
|
||||
render(VarietyWarningCards, { props: { warnings, weekStart: '2026-04-07' } });
|
||||
expect(screen.getByText('Chicken Tikka')).toBeTruthy();
|
||||
expect(screen.getByText('Chicken Curry')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nothing when warnings is empty', () => {
|
||||
render(VarietyWarningCards, { props: { warnings: [], weekStart: '2026-04-07' } });
|
||||
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.heroImagePreview}
|
||||
<img src={recipe.heroImagePreview} 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',
|
||||
heroImagePreview: 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 heroImagePreview', () => {
|
||||
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image when heroImagePreview is provided', () => {
|
||||
render(RecipeCard, {
|
||||
props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } }
|
||||
});
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc');
|
||||
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]');
|
||||
});
|
||||
});
|
||||
368
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
368
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
@@ -0,0 +1,368 @@
|
||||
<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))();
|
||||
|
||||
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||
dietary: 'Ernährung',
|
||||
cuisine: 'Küche',
|
||||
protein: 'Protein',
|
||||
other: 'Sonstiges'
|
||||
};
|
||||
|
||||
const groupedCategories = $derived(
|
||||
Object.entries(
|
||||
categories.reduce<Record<string, typeof categories>>((acc, cat) => {
|
||||
const type = cat.tagType ?? 'other';
|
||||
(acc[type] ??= []).push(cat);
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
);
|
||||
|
||||
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) ?? ['']);
|
||||
let heroImageUrl = $state<string | null>(initial?.heroImageUrl ?? null);
|
||||
let imageError = $state<string | null>(null);
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
function handleImageChange(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
imageError = 'Datei zu groß. Maximal 5 MB erlaubt.';
|
||||
(e.currentTarget as HTMLInputElement).value = '';
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
imageError = 'Dateityp nicht unterstützt. Erlaubt: JPEG, PNG, GIF, WebP.';
|
||||
(e.currentTarget as HTMLInputElement).value = '';
|
||||
return;
|
||||
}
|
||||
imageError = null;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
heroImageUrl = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
</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 (min)
|
||||
</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>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Bild</p>
|
||||
{#if heroImageUrl}
|
||||
<img
|
||||
src={heroImageUrl}
|
||||
alt=""
|
||||
class="mb-[8px] max-h-[200px] w-full rounded-[var(--radius-md)] object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (heroImageUrl = null)}
|
||||
class="mb-[8px] text-[12px] text-[var(--color-error)] opacity-60 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
Bild entfernen
|
||||
</button>
|
||||
{/if}
|
||||
<label
|
||||
class="block w-full cursor-pointer rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] text-center text-[13px] text-[var(--color-text-muted)]"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleImageChange}
|
||||
class="sr-only"
|
||||
/>
|
||||
{heroImageUrl ? 'Bild ändern' : 'Bild hochladen'}
|
||||
</label>
|
||||
{#if imageError}
|
||||
<p class="mt-[6px] text-[12px] text-[var(--color-error)]">{imageError}</p>
|
||||
{:else}
|
||||
<p class="mt-[6px] text-[11px] text-[var(--color-text-muted)]">Max. 5 MB</p>
|
||||
{/if}
|
||||
<input type="hidden" name="heroImageUrl" value={heroImageUrl ?? ''} />
|
||||
</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-[16px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
||||
{#each groupedCategories as [type, tags] (type)}
|
||||
<div class="mb-[16px] last:mb-0">
|
||||
<p class="mb-[8px] text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{TAG_TYPE_LABELS[type] ?? type}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each tags 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>
|
||||
{/each}
|
||||
</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>
|
||||
214
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
214
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('shows Max. 5 MB hint below upload button', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByText('Max. 5 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when selected file exceeds 5 MB', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const oversizedFile = new File(['x'.repeat(6 * 1024 * 1024)], 'big.jpg', { type: 'image/jpeg' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, oversizedFile);
|
||||
|
||||
expect(screen.getByText(/datei zu groß/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show file size error for file within 5 MB', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const okFile = new File(['x'.repeat(1 * 1024 * 1024)], 'small.jpg', { type: 'image/jpeg' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, okFile);
|
||||
|
||||
expect(screen.queryByText(/datei zu groß/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when selected file has unsupported type', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const bmpFile = new File(['content'], 'image.bmp', { type: 'image/bmp' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, bmpFile);
|
||||
|
||||
expect(screen.getByText(/dateityp/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show type error for supported image types', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
|
||||
const jpgFile = new File(['content'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await user.upload(fileInput, jpgFile);
|
||||
|
||||
expect(screen.queryByText(/dateityp/i)).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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user