feat(planner): wire variety-aware suggestions into RecipePicker for empty slots #47

Merged
marcel merged 30 commits from feat/issue-46-wire-suggestions-recipe-picker into master 2026-04-09 16:33:12 +02:00
21 changed files with 965 additions and 626 deletions

View File

@@ -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();
@@ -168,12 +174,22 @@ public class PlanningService {
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
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)
@@ -191,11 +207,7 @@ public class PlanningService {
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
List<SimulatedSlot> currentSlots = plan.getSlots().stream()
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
.toList();
double currentScore = currentSlots.isEmpty() ? 10.0
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds);
return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore);
@@ -204,10 +216,6 @@ public class PlanningService {
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<>();
@@ -247,12 +255,17 @@ public class PlanningService {
.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 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)
@@ -269,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
@@ -340,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);
}

View File

@@ -6,6 +6,7 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
public record SuggestionItem(
SlotResponse.SlotRecipe recipe,
double simulatedScore
double scoreDelta,
boolean hasConflict
) {}
}

View File

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

View File

@@ -162,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);
@@ -175,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

File diff suppressed because one or more lines are too long

View File

@@ -914,7 +914,8 @@ export interface components {
SuggestionItem: {
recipe?: components["schemas"]["SlotRecipe"];
/** Format: double */
simulatedScore?: number;
scoreDelta?: number;
hasConflict?: boolean;
};
SuggestionResponse: {
suggestions?: components["schemas"]["SuggestionItem"][];

View File

@@ -17,9 +17,10 @@
slot: Slot;
onswap: () => void;
oncancel: () => void;
onremove?: () => void;
}
let { open, slot, onswap, oncancel }: Props = $props();
let { open, slot, onswap, oncancel, onremove }: Props = $props();
const meta = $derived.by(() => {
const parts: string[] = [];
@@ -82,6 +83,16 @@
↻ 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"

View File

@@ -13,7 +13,8 @@ const baseProps = {
open: true,
slot,
onswap: vi.fn(),
oncancel: vi.fn()
oncancel: vi.fn(),
onremove: vi.fn()
};
describe('MealActionSheet', () => {
@@ -28,14 +29,29 @@ describe('MealActionSheet', () => {
expect(screen.getByText(/easy/i)).toBeTruthy();
});
it('renders all 4 action buttons', () => {
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 });

View File

@@ -1,40 +1,50 @@
<script lang="ts">
interface Recipe {
id: string;
name: string;
effort?: string;
cookTimeMin?: number;
}
interface Suggestion {
recipe: Recipe;
simulatedScore: number;
}
import type { Recipe, Suggestion } from '$lib/planner/types';
let {
planId,
date,
dateLabel,
currentVarietyScore = 0,
suggestions = [],
allRecipes = [],
isLoading = false,
isDisabled = false,
excludeRecipeId,
replacingRecipe,
onpick
}: {
planId: string;
date: string;
dateLabel: string;
currentVarietyScore?: number;
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() === ''
? allRecipes
: allRecipes.filter((r) =>
? baseRecipes
: baseRecipes.filter((r) =>
r.name.toLowerCase().includes(searchQuery.toLowerCase())
)
);
@@ -49,16 +59,62 @@
}
</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 -->
<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>
<!-- 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);">
@@ -71,98 +127,118 @@
</div>
<!-- Empfohlen section -->
{#if suggestions.length > 0}
<div
style="font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
>
Empfohlen · Beste Abwechslung
</div>
{#each suggestions as suggestion (suggestion.recipe.id)}
{@const delta = suggestion.simulatedScore - currentVarietyScore}
{@const meta = recipeMetadata(suggestion.recipe)}
<div
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
>
<div style="flex: 1; min-width: 0;">
<p
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
>
{suggestion.recipe.name}
</p>
{#if meta}
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
{meta}
</p>
{/if}
{#if delta > 0}
<span
data-testid="badge-{suggestion.recipe.id}"
data-type="good"
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
>
↑ +{delta.toFixed(0)} Punkte
</span>
{:else}
<span
data-testid="badge-{suggestion.recipe.id}"
data-type="warning"
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
>
⚠ Variationskonflikt
</span>
{/if}
</div>
<button
type="button"
aria-label="Wählen"
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
{#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;"
>
+ Wählen
</button>
<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}
{#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
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>
<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)}
<div
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
>
<div style="flex: 1; min-width: 0;">
<p
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
>
{recipe.name}
</p>
{#if meta}
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
{meta}
</p>
{/if}
</div>
<button
type="button"
aria-label="Wählen"
onclick={() => onpick(recipe.id, recipe.name)}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green); color: #fff; border: none; cursor: pointer;"
{#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;"
>
+ Wählen
</button>
</div>
{/each}
{/if}
<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>

View File

@@ -1,11 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
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 }, simulatedScore: 9.5 },
{ recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, simulatedScore: 6.0 }
{ 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 = [
@@ -18,7 +18,6 @@ const baseProps = {
planId: 'plan-1',
date: '2026-04-05',
dateLabel: 'Samstag, 5. April',
currentVarietyScore: 7.5,
suggestions,
allRecipes,
onpick: vi.fn()
@@ -35,24 +34,32 @@ describe('RecipePicker', () => {
expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
});
it('shows all suggestion recipe names', () => {
it('shows only positive-delta suggestions in Empfohlen', () => {
render(RecipePicker, { props: baseProps });
// s1 (scoreDelta=1.5) appears in Empfohlen
expect(screen.getByText('Lachsfilet')).toBeTruthy();
expect(screen.getByText('Hähnchen-Curry')).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 for suggestions with positive delta', () => {
it('shows green badge when hasConflict is false', () => {
render(RecipePicker, { props: baseProps });
// Lachsfilet: simulatedScore 9.5 - currentVarietyScore 7.5 = +2 → green badge
// Lachsfilet: hasConflict = false → green badge
const badge = screen.getByTestId('badge-s1');
expect(badge.getAttribute('data-type')).toBe('good');
});
it('shows yellow badge for suggestions with zero or negative delta', () => {
render(RecipePicker, { props: baseProps });
// Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge
const badge = screen.getByTestId('badge-s2');
expect(badge.getAttribute('data-type')).toBe('warning');
it('shows 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', () => {
@@ -87,8 +94,8 @@ describe('RecipePicker', () => {
const onpick = vi.fn();
render(RecipePicker, { props: { ...baseProps, onpick } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
// First 2 are suggestions, rest are allRecipes
await userEvent.click(buttons[2]);
// First 1 is the positive-delta suggestion (s1), rest are allRecipes
await userEvent.click(buttons[1]);
expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon');
});
@@ -98,4 +105,119 @@ describe('RecipePicker', () => {
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));
});
});

View File

@@ -1,83 +0,0 @@
<script lang="ts">
interface SlotRecipe {
id?: string;
name?: string;
effort?: string;
cookTimeMin?: number;
}
interface Suggestion {
recipe?: SlotRecipe;
simulatedScore?: number;
reasoningType?: 'good' | 'warning';
reasoningLabel?: string;
}
let {
suggestion,
rank,
planId,
slotDate,
weekStart
}: {
suggestion: Suggestion;
rank: number;
planId: string;
slotDate: string;
weekStart: string;
} = $props();
let metadata = $derived(
[
suggestion.recipe?.cookTimeMin != null ? `${suggestion.recipe.cookTimeMin} Min` : null,
suggestion.recipe?.effort ?? null
]
.filter(Boolean)
.join(' · ')
);
</script>
<div class="flex items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 shadow-[var(--shadow-card)]">
<!-- Rank number -->
<div class="w-10 flex-shrink-0 self-start text-right">
<span class="font-[var(--font-display)] text-[32px] font-[300] leading-none text-[var(--color-text-muted)]">{rank}</span>
</div>
<!-- Card content -->
<div class="flex-1 min-w-0">
<p class="font-[var(--font-sans)] text-[15px] font-medium text-[var(--color-text)] line-clamp-2">
{suggestion.recipe?.name ?? 'Unbekanntes Rezept'}
</p>
{#if metadata}
<p class="mt-0.5 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
{/if}
<!-- Reasoning badge -->
{#if suggestion.reasoningType && suggestion.reasoningLabel}
<div
data-testid="reasoning-badge"
data-type={suggestion.reasoningType}
class="mt-2 inline-flex items-center rounded-full px-2 py-0.5 font-[var(--font-sans)] text-[11px] font-medium
{suggestion.reasoningType === 'good'
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
: 'bg-[var(--yellow-tint)] text-[var(--yellow-text)]'}"
>
{suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel}
</div>
{/if}
</div>
<!-- Pick action -->
<form method="POST" action="?/pickSuggestion" class="flex-shrink-0">
<input type="hidden" name="planId" value={planId} />
<input type="hidden" name="recipeId" value={suggestion.recipe?.id} />
<input type="hidden" name="slotDate" value={slotDate} />
<input type="hidden" name="weekStart" value={weekStart} />
<button
type="submit"
class="font-[var(--font-sans)] text-[13px] font-medium tracking-[0.04em] text-[var(--green-dark)] hover:underline"
>
Wählen
</button>
</form>
</div>

View File

@@ -1,60 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import SuggestionCard from './SuggestionCard.svelte';
const goodSuggestion = {
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
simulatedScore: 9.2,
reasoningType: 'good' as const,
reasoningLabel: 'Frisches Protein · Aufwandsbalance'
};
const warningSuggestion = {
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
simulatedScore: 6.1,
reasoningType: 'warning' as const,
reasoningLabel: 'Hähnchen schon 2 Tage dabei'
};
describe('SuggestionCard', () => {
it('renders recipe name', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
});
it('renders rank number', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText('1')).toBeTruthy();
});
it('renders cook time and effort metadata', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText(/25 Min/)).toBeTruthy();
expect(screen.getByText(/Easy/)).toBeTruthy();
});
it('renders green reasoning badge for good suggestions', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
const badge = screen.getByTestId('reasoning-badge');
expect(badge.getAttribute('data-type')).toBe('good');
expect(badge.textContent).toContain('Frisches Protein');
});
it('renders yellow reasoning badge for warnings', () => {
render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
const badge = screen.getByTestId('reasoning-badge');
expect(badge.getAttribute('data-type')).toBe('warning');
expect(badge.textContent).toContain('Hähnchen');
});
it('renders a pick button/form', () => {
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy();
});
it('card without reasoning renders without crashing', () => {
const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined };
render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
});
});

View File

@@ -1,126 +0,0 @@
<script lang="ts">
interface Recipe {
id: string;
name: string;
effort?: string | null;
cookTimeMin?: number | null;
}
let {
replacingName,
replacingMeta,
recipes,
currentWeekRecipeIds,
excludeRecipeId,
isLoading = false,
onpick,
oncancel
}: {
replacingName: string;
replacingMeta?: string;
recipes: Recipe[];
currentWeekRecipeIds: Set<string>;
excludeRecipeId?: string;
isLoading?: boolean;
onpick: (recipeId: string, recipeName: string) => void;
oncancel?: () => void;
} = $props();
let visibleRecipes = $derived(
excludeRecipeId ? recipes.filter((r) => r.id !== excludeRecipeId) : recipes
);
function recipeMeta(recipe: Recipe): string {
return [
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} min` : null,
recipe.effort ?? null
]
.filter(Boolean)
.join(' · ');
}
</script>
<!-- Replacing banner -->
<div
style="background: var(--orange-tint); border: 1px solid #FBCDA4; border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 14px;"
>
<p
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 4px 0; font-family: var(--font-sans);"
>
Wird ersetzt
</p>
<span
data-testid="replacing-name"
title={replacingName}
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 14px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
>
{replacingName}{#if replacingMeta} · {replacingMeta}{/if}
</span>
</div>
<!-- Eyebrow label -->
<p
style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); margin: 0 0 6px 0; font-family: var(--font-sans);"
>
Ersetzen durch (einfachste zuerst)
</p>
<!-- Recipe list -->
{#if visibleRecipes.length === 0}
<p
data-testid="swap-empty-state"
style="text-align: center; color: var(--color-text-muted); font-family: var(--font-sans); margin: 0;"
>
Keine Rezepte verfügbar.
</p>
{:else}
{#each visibleRecipes as recipe (recipe.id)}
{@const meta = recipeMeta(recipe)}
{@const alreadyPlanned = currentWeekRecipeIds.has(recipe.id)}
<div
style="background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 6px; display: flex; align-items: center; gap: 8px;"
>
<div style="flex: 1; min-width: 0;">
<p
style="font-family: var(--font-display); font-size: 13px; color: var(--color-text); margin: 0;"
>
{recipe.name}
</p>
{#if meta}
<p
style="font-size: 9px; color: var(--color-text-muted); font-family: var(--font-sans); margin: 1px 0 0;"
>
{meta}
</p>
{/if}
{#if alreadyPlanned}
<p
data-testid="already-planned-{recipe.id}"
style="font-size: 9px; color: var(--yellow-text); font-family: var(--font-sans); margin: 1px 0 0;"
>
⚠ Bereits diese Woche
</p>
{/if}
</div>
<button
type="button"
onclick={() => onpick(recipe.id, recipe.name)}
disabled={isLoading}
style="background: none; border: none; cursor: {isLoading ? 'default' : 'pointer'}; font-size: 11px; font-weight: 500; color: var(--green); font-family: var(--font-sans); flex-shrink: 0; opacity: {isLoading ? '0.4' : '1'};"
>
Wählen
</button>
</div>
{/each}
{/if}
<!-- Cancel button (optional) -->
{#if oncancel}
<button
type="button"
onclick={oncancel}
style="width: 100%; background: none; border: none; cursor: pointer; color: var(--color-text-muted); font-size: 13px; text-align: center; padding: 8px 0; font-family: var(--font-sans);"
>
Abbrechen
</button>
{/if}

View File

@@ -1,120 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import SwapSuggestionList from './SwapSuggestionList.svelte';
const recipes = [
{ id: 'r1', name: 'Quick carbonara', effort: 'easy', cookTimeMin: 20 },
{ id: 'r2', name: 'Chicken stir-fry', effort: 'easy', cookTimeMin: 25 },
{ id: 'r3', name: 'Mushroom risotto', effort: 'medium', cookTimeMin: 50 }
];
const baseProps = {
replacingName: 'Tomato pasta',
replacingMeta: '45 min · Easy',
recipes,
currentWeekRecipeIds: new Set<string>(),
onpick: vi.fn()
};
describe('SwapSuggestionList', () => {
it('renders the Replacing banner', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
});
it('renders old meal name with strikethrough', () => {
render(SwapSuggestionList, { props: baseProps });
const struck = screen.getByTestId('replacing-name');
expect(struck.textContent).toContain('Tomato pasta');
expect(getComputedStyle(struck).textDecoration || struck.style.textDecoration).toContain('line-through');
});
it('replacing-name span has title attribute for full name', () => {
render(SwapSuggestionList, { props: baseProps });
const struck = screen.getByTestId('replacing-name');
expect(struck.getAttribute('title')).toBe('Tomato pasta');
});
it('renders the easiest-first eyebrow label', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText(/einfachste zuerst/i)).toBeTruthy();
});
it('renders all recipe names', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText('Quick carbonara')).toBeTruthy();
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
});
it('clicking Wählen calls onpick with recipeId and name', async () => {
const onpick = vi.fn();
const user = userEvent.setup();
render(SwapSuggestionList, { props: { ...baseProps, onpick } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
await user.click(buttons[0]);
expect(onpick).toHaveBeenCalledWith('r1', 'Quick carbonara');
});
it('shows already-planned warning for recipes in currentWeekRecipeIds', () => {
render(SwapSuggestionList, {
props: { ...baseProps, currentWeekRecipeIds: new Set(['r2']) }
});
expect(screen.getByTestId('already-planned-r2')).toBeTruthy();
});
it('does not show already-planned warning for recipes not in currentWeekRecipeIds', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.queryByTestId('already-planned-r1')).toBeNull();
});
it('shows empty state when no recipes', () => {
render(SwapSuggestionList, { props: { ...baseProps, recipes: [] } });
expect(screen.getByTestId('swap-empty-state')).toBeTruthy();
});
it('excludes the recipe being replaced when excludeRecipeId is provided', () => {
render(SwapSuggestionList, { props: { ...baseProps, excludeRecipeId: 'r2' } });
expect(screen.queryByText('Chicken stir-fry')).toBeNull();
expect(screen.getByText('Quick carbonara')).toBeTruthy();
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
});
it('shows all recipes when excludeRecipeId is not provided', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.getByText('Quick carbonara')).toBeTruthy();
expect(screen.getByText('Chicken stir-fry')).toBeTruthy();
expect(screen.getByText('Mushroom risotto')).toBeTruthy();
});
it('disables all Wählen buttons when isLoading is true', () => {
render(SwapSuggestionList, { props: { ...baseProps, isLoading: true } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
});
it('Wählen buttons are enabled when isLoading is false', () => {
render(SwapSuggestionList, { props: { ...baseProps, isLoading: false } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
});
it('renders optional Abbrechen button when oncancel provided', () => {
render(SwapSuggestionList, { props: { ...baseProps, oncancel: vi.fn() } });
expect(screen.getByRole('button', { name: /Abbrechen/i })).toBeTruthy();
});
it('does not render Abbrechen button when oncancel not provided', () => {
render(SwapSuggestionList, { props: baseProps });
expect(screen.queryByRole('button', { name: /Abbrechen/i })).toBeNull();
});
it('clicking Abbrechen calls oncancel', async () => {
const oncancel = vi.fn();
const user = userEvent.setup();
render(SwapSuggestionList, { props: { ...baseProps, oncancel } });
await user.click(screen.getByRole('button', { name: /Abbrechen/i }));
expect(oncancel).toHaveBeenCalledOnce();
});
});

View File

@@ -20,7 +20,7 @@
<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}
{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>

View File

@@ -51,7 +51,13 @@ describe('VarietyScoreCard', () => {
it('renders with score 0', () => {
render(VarietyScoreCard, { props: { ...baseProps, score: 0 } });
expect(screen.getByText('0')).toBeTruthy();
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', () => {

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

View File

@@ -7,10 +7,10 @@
import DayMealCard from '$lib/planner/DayMealCard.svelte';
import RecipePicker from '$lib/planner/RecipePicker.svelte';
import MealActionSheet from '$lib/planner/MealActionSheet.svelte';
import SwapSuggestionList from '$lib/planner/SwapSuggestionList.svelte';
import BottomSheet from '$lib/components/BottomSheet.svelte';
import UndoBar from '$lib/planner/UndoBar.svelte';
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange, sortEasiestFirst } from '$lib/planner/week';
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
import type { Suggestion } from '$lib/planner/types';
let { data, form = null }: { data: { weekPlan: any; varietyScore: any; weekStart: string; recipes: any[] }; form?: any } = $props();
@@ -63,14 +63,21 @@
let swapSheetOpen = $state(false);
let swapLoading = $state(false);
const activePickerDate = $derived(
pickerOpen ? selectedDay
: swapSheetOpen ? selectedDay
: panelState.kind === 'recipe-picker' ? panelState.date
: null
);
let suggestions: Suggestion[] = $state([]);
let isLoadingSuggestions = $state(false);
// Recipes already in any slot this week — used for ⚠ overlap warnings
let currentWeekRecipeIds = $derived(
new Set<string>(slots.filter((s: any) => s.recipe?.id).map((s: any) => s.recipe.id))
);
// Recipes sorted easiest-first for the swap suggestion list
let sortedRecipes = $derived(sortEasiestFirst(data.recipes));
// Hidden form field bindings
let addPlanId = $state('');
let addSlotDate = $state('');
@@ -90,6 +97,23 @@
// UndoBar
let undoVisible = $state(false);
let undoMessage = $state('');
let undoCallback = $state<(() => void) | null>(null);
$effect(() => {
if (!activePickerDate || !weekPlan?.id) {
suggestions = [];
isLoadingSuggestions = false;
return;
}
const controller = new AbortController();
isLoadingSuggestions = true;
fetch(`/planner?planId=${weekPlan.id}&date=${activePickerDate}`, { signal: controller.signal })
.then((r) => r.json())
.then((d) => { suggestions = d.suggestions ?? []; })
.catch((e) => { if (e.name !== 'AbortError') suggestions = []; })
.finally(() => { isLoadingSuggestions = false; });
return () => controller.abort();
});
function handleSelectDay(day: string) {
selectedDay = day;
@@ -136,7 +160,33 @@
function handleUndo() {
undoVisible = false;
undoCallback?.();
}
async function handleRemoveMeal(slot: { id: string; slotDate: string; recipe: { id: string; name: string } | null }) {
// Capture primitive values immediately — slot may be a reactive proxy that
// becomes stale after the first await (tick flushes state + re-render).
const slotId = slot.id;
const slotDate = slot.slotDate;
const recipeName = slot.recipe?.name ?? '';
const recipeId = slot.recipe?.id ?? '';
if (!slotId || !recipeId) return;
actionSheetOpen = false;
undoCallback = async () => {
addPlanId = weekPlan!.id;
addSlotDate = slotDate;
addRecipeId = recipeId;
addRecipeName = recipeName;
await tick();
addSlotFormEl.requestSubmit();
};
delPlanId = weekPlan!.id;
delSlotId = slotId;
await tick();
deleteSlotFormEl.requestSubmit();
undoMessage = `${recipeName} entfernt`;
undoVisible = true;
}
async function handleSwapPick(recipeId: string, recipeName: string) {
@@ -282,19 +332,20 @@
planId={weekPlan?.id ?? ''}
date={selectedDay}
dateLabel={formatDayLabel(selectedDay)}
currentVarietyScore={varietyScore?.score ?? 0}
suggestions={[]}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
onpick={handleRecipePick}
/>
</BottomSheet>
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Cancel) -->
<!-- Mobile: meal exists → action sheet (Swap / Cook / View / Remove / Cancel) -->
<MealActionSheet
open={actionSheetOpen}
slot={selectedSlot}
onswap={() => { actionSheetOpen = false; swapSheetOpen = true; }}
oncancel={() => (actionSheetOpen = false)}
onremove={isPlanner && selectedSlot.id ? () => handleRemoveMeal(selectedSlot as any) : undefined}
/>
<!-- Mobile: swap suggestions sheet -->
@@ -303,18 +354,18 @@
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
selectedSlot.recipe?.effort ?? null
].filter(Boolean).join(' · ')}
<div style="padding: 16px;">
<SwapSuggestionList
replacingName={selectedSlot.recipe?.name ?? ''}
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
excludeRecipeId={selectedSlot.recipe?.id}
isLoading={swapLoading}
onpick={handleSwapPick}
oncancel={() => (swapSheetOpen = false)}
/>
</div>
<RecipePicker
planId={weekPlan?.id ?? ''}
date={selectedDay}
dateLabel={formatDayLabel(selectedDay)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
isDisabled={swapLoading}
excludeRecipeId={selectedSlot.recipe?.id}
replacingRecipe={selectedSlot.recipe ? { name: selectedSlot.recipe.name, meta: replacingMeta || undefined } : undefined}
onpick={handleSwapPick}
/>
</BottomSheet>
</div>
@@ -504,6 +555,15 @@
>
Gericht tauschen
</button>
{#if detailSlot.id}
<button
type="button"
onclick={() => { handleRemoveMeal(detailSlot as any); panelState = { kind: 'idle' }; }}
class="block w-full rounded-[var(--radius-md)] border border-[var(--color-error,#d9534f)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-error,#d9534f)] hover:bg-[var(--color-surface)]"
>
Entfernen
</button>
{/if}
{/if}
</div>
{:else}
@@ -544,13 +604,16 @@
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
pickerSlot.recipe.effort ?? null
].filter(Boolean).join(' · ')}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4 px-4">
<SwapSuggestionList
replacingName={pickerSlot.recipe.name}
replacingMeta={replacingMeta || undefined}
recipes={sortedRecipes}
{currentWeekRecipeIds}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<RecipePicker
planId={weekPlan?.id ?? ''}
date={pickerDate}
dateLabel={formatDayLabel(pickerDate)}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
excludeRecipeId={pickerSlot.recipe.id}
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
onpick={handleRecipePick}
/>
</div>
@@ -560,9 +623,9 @@
planId={weekPlan?.id ?? ''}
date={pickerDate}
dateLabel={formatDayLabel(pickerDate)}
currentVarietyScore={varietyScore?.score ?? 0}
suggestions={[]}
suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
onpick={handleRecipePick}
/>
</div>
@@ -585,8 +648,10 @@
formData.set('recipeId', addRecipeId);
return async ({ result, update }) => {
if (result.type === 'success' && result.data?.success) {
const slotId = (result.data as any)?.slot?.id ?? '';
delPlanId = addPlanId;
delSlotId = (result.data as any)?.slot?.id ?? '';
delSlotId = slotId;
undoCallback = () => deleteSlotFormEl.requestSubmit();
undoMessage = `${addRecipeName} hinzugefügt`;
undoVisible = true;
}
@@ -613,6 +678,7 @@
if (result.type === 'success' && result.data?.success) {
delPlanId = updPlanId;
delSlotId = (result.data as any)?.slot?.id ?? '';
undoCallback = () => deleteSlotFormEl.requestSubmit();
undoMessage = `${updRecipeName} eingetragen`;
undoVisible = true;
}

View File

@@ -11,14 +11,18 @@ export const GET: RequestHandler = async ({ fetch, url }) => {
return json({ suggestions: [] });
}
const api = apiClient(fetch);
const { data } = await api.GET('/v1/week-plans/{id}/suggestions', {
params: { path: { id: planId }, query: { slotDate: date } }
});
try {
const api = apiClient(fetch);
const { data } = await api.GET('/v1/week-plans/{id}/suggestions', {
params: { path: { id: planId }, query: { slotDate: date, topN: 100 } }
});
const suggestions = (data?.suggestions ?? []).sort(
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
);
const suggestions = (data?.suggestions ?? []).sort(
(a: any, b: any) => (b.scoreDelta ?? 0) - (a.scoreDelta ?? 0)
);
return json({ suggestions });
return json({ suggestions });
} catch {
return json({ suggestions: [] });
}
};

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import Page from './+page.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
vi.mock('$app/forms', () => ({
enhance: () => () => ({ destroy: () => {} })
}));
const PLAN_ID = 'plan-00000000-0000-0000-0000-000000000001';
// Use a past week so "today" is never in this range — selectedDay defaults to weekStart (Monday)
const DATE = '2025-01-06'; // Monday, January 6 2025
const mockData = {
weekPlan: { id: PLAN_ID, weekStart: DATE, status: 'draft', slots: [] as any[] },
varietyScore: null,
weekStart: DATE,
recipes: [{ id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }],
benutzer: { rolle: 'planer' }
};
const mockDataWithSlot = {
...mockData,
weekPlan: {
id: PLAN_ID,
weekStart: DATE,
status: 'draft',
slots: [{ id: 'slot-1', slotDate: DATE, recipe: { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 } }]
}
};
const mockSuggestions = [
{
recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 20 },
scoreDelta: 1.5,
hasConflict: false
}
];
describe('+page.svelte — $effect suggestion fetch', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('calls fetch when picker opens with correct planId and date', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce({
json: () => Promise.resolve({ suggestions: mockSuggestions })
})
);
render(Page, { props: { data: mockData } });
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`);
expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`);
});
it('shows suggestions in RecipePicker after fetch resolves', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce({
json: () => Promise.resolve({ suggestions: mockSuggestions })
})
);
render(Page, { props: { data: mockData } });
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
expect(await screen.findByText('Lachsfilet')).toBeTruthy();
});
it('passes AbortSignal to fetch so inflight requests can be cancelled', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce({
json: () => Promise.resolve({ suggestions: [] })
})
);
render(Page, { props: { data: mockData } });
await userEvent.click(screen.getAllByRole('button', { name: /Gericht/i })[0]);
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
const fetchOptions = (fetch as any).mock.calls[0][1];
expect(fetchOptions?.signal).toBeInstanceOf(AbortSignal);
});
});
describe('+page.svelte — swap sheet suggestion fetch', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('opening mobile swap sheet triggers fetch with planId and date', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
render(Page, { props: { data: mockDataWithSlot } });
// Open action sheet, then swap sheet
await userEvent.click(screen.getByTestId('day-meal-card'));
await userEvent.click(await screen.findByRole('button', { name: /Gericht tauschen/i }));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect((fetch as any).mock.calls[0][0]).toContain(`planId=${PLAN_ID}`);
expect((fetch as any).mock.calls[0][0]).toContain(`date=${DATE}`);
});
});
describe('+page.svelte — remove meal', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('clicking Entfernen in MealActionSheet shows undo bar with recipe name', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
render(Page, { props: { data: mockDataWithSlot } });
await userEvent.click(screen.getByTestId('day-meal-card'));
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
const undoBar = screen.getByTestId('undo-bar');
expect(undoBar).toBeTruthy();
expect(within(undoBar).getByText(/Beef Bourguignon/)).toBeTruthy();
});
it('clicking Rückgängig after remove hides the undo bar', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve({ suggestions: [] }) }));
render(Page, { props: { data: mockDataWithSlot } });
await userEvent.click(screen.getByTestId('day-meal-card'));
await userEvent.click(await screen.findByRole('button', { name: /Entfernen/i }));
await userEvent.click(screen.getByRole('button', { name: /Rückgängig/i }));
expect(screen.queryByTestId('undo-bar')).toBeNull();
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
const PLAN_UUID = '11111111-1111-1111-1111-111111111111';
const DATE = '2026-04-09';
const mockSuggestions = [
{ recipe: { id: 'r1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, scoreDelta: 0.0, hasConflict: true },
{ recipe: { id: 'r2', name: 'Nudeln', effort: 'easy', cookTimeMin: 20 }, scoreDelta: -1.5, hasConflict: true }
];
describe('GET /planner — suggestions route handler', () => {
let GET: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+server');
GET = mod.GET;
});
it('returns { suggestions: [] } when planId is missing', async () => {
const url = new URL('http://localhost/planner?date=' + DATE);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
expect(mockGet).not.toHaveBeenCalled();
});
it('returns { suggestions: [] } when date is missing', async () => {
const url = new URL('http://localhost/planner?planId=' + PLAN_UUID);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
expect(mockGet).not.toHaveBeenCalled();
});
it('returns sorted suggestions from backend on success', async () => {
mockGet.mockResolvedValueOnce({ data: { suggestions: mockSuggestions }, error: undefined });
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
params: { path: { id: PLAN_UUID }, query: { slotDate: DATE, topN: 100 } }
}));
expect(body.suggestions).toHaveLength(2);
// sorted by scoreDelta desc: 0.0 before -1.5
expect(body.suggestions[0].recipe.name).toBe('Lachsfilet');
expect(body.suggestions[1].recipe.name).toBe('Nudeln');
});
it('returns { suggestions: [] } when data is undefined (error response without data)', async () => {
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
});
it('returns { suggestions: [] } when backend throws (network error)', async () => {
mockGet.mockRejectedValueOnce(new Error('Network error'));
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
});
it('returns empty suggestions when backend returns empty array', async () => {
mockGet.mockResolvedValueOnce({ data: { suggestions: [] }, error: undefined });
const url = new URL(`http://localhost/planner?planId=${PLAN_UUID}&date=${DATE}`);
const response = await GET({ fetch: vi.fn(), url });
const body = await response.json();
expect(body).toEqual({ suggestions: [] });
});
});