From 7175b56833b364bf3b241a8a581efe4199260708 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Wed, 8 Apr 2026 22:36:03 +0200 Subject: [PATCH] feat(planning): add GET /v1/week-plans/{planId}/variety-preview endpoint Returns currentScore, projectedScore, and scoreDelta when a recipe would be added on a given date. Used by C6 desktop day picker. Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/planning/PlanningService.java | 76 +++++++++++++++++++ .../planning/WeekPlanController.java | 10 +++ .../planning/dto/VarietyPreviewResponse.java | 7 ++ .../planning/PlanningServiceTest.java | 58 ++++++++++++++ .../planning/WeekPlanControllerTest.java | 19 +++++ 5 files changed, 170 insertions(+) create mode 100644 backend/src/main/java/com/recipeapp/planning/dto/VarietyPreviewResponse.java diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 048c058..6d78414 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -232,6 +232,82 @@ public class PlanningService { 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 recentlyCookedIds = cookingLogRepository + .findByHouseholdIdAndCookedOnAfter(householdId, + plan.getWeekStart().minusDays(config.getHistoryDays())) + .stream() + .map(cl -> cl.getRecipe().getId()) + .collect(Collectors.toSet()); + + // Current score: simulate with only existing slots + double currentScore = computeCurrentScore(plan, config, recentlyCookedIds); + + // Projected score: add candidate on the given date + double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds); + + return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore); + } + + private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set recentlyCookedIds) { + List slots = plan.getSlots(); + if (slots.isEmpty()) return 10.0; + + List checkedTagTypes = config.getRepeatTagTypes(); + double wTagRepeat = config.getWTagRepeat().doubleValue(); + double wIngredientOverlap = config.getWIngredientOverlap().doubleValue(); + double wRecentRepeat = config.getWRecentRepeat().doubleValue(); + double wPlanDuplicate = config.getWPlanDuplicate().doubleValue(); + + Map> tagDays = new LinkedHashMap<>(); + for (WeekPlanSlot slot : slots) { + for (Tag tag : slot.getRecipe().getTags()) { + if (checkedTagTypes.contains(tag.getTagType())) { + tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.getSlotDate()); + } + } + } + long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count(); + + Map> ingredientDays = new LinkedHashMap<>(); + for (WeekPlanSlot slot : slots) { + for (RecipeIngredient ri : slot.getRecipe().getIngredients()) { + if (!ri.getIngredient().isStaple()) { + ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) + .add(slot.getSlotDate()); + } + } + } + long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count(); + + long recentRepeatCount = slots.stream() + .map(s -> s.getRecipe().getId()) + .distinct() + .filter(recentlyCookedIds::contains) + .count(); + + Map recipeCounts = slots.stream() + .collect(Collectors.groupingBy(s -> s.getRecipe().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)); + } + @Transactional(readOnly = true) public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) { WeekPlan plan = findPlan(planId, householdId); diff --git a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java index 30775fd..c86183f 100644 --- a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java +++ b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java @@ -96,4 +96,14 @@ public class WeekPlanController { UUID householdId = householdResolver.resolve(principal.getName()); return planningService.getVarietyScore(householdId, id); } + + @GetMapping("/{planId}/variety-preview") + 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); + } } diff --git a/backend/src/main/java/com/recipeapp/planning/dto/VarietyPreviewResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/VarietyPreviewResponse.java new file mode 100644 index 0000000..5f78407 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/planning/dto/VarietyPreviewResponse.java @@ -0,0 +1,7 @@ +package com.recipeapp.planning.dto; + +public record VarietyPreviewResponse( + double currentScore, + double projectedScore, + double scoreDelta +) {} diff --git a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java index 670c50b..b5b4aa2 100644 --- a/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java +++ b/backend/src/test/java/com/recipeapp/planning/PlanningServiceTest.java @@ -443,4 +443,62 @@ class PlanningServiceTest { assertThatThrownBy(() -> planningService.createWeekPlan(HOUSEHOLD_ID, WEEK_START)) .isInstanceOf(ResourceNotFoundException.class); } + + // ── Variety preview ── + + @Test + void getVarietyPreviewShouldReturnScoreDelta() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var planId = plan.getId(); + + // Plan already has one slot (Mon) + var existingRecipe = testRecipe(household, "Spaghetti"); + var slot = new WeekPlanSlot(plan, existingRecipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + 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)); + + // With no penalties, projected score should be 10.0; current (1 slot, no conflicts) is also 10.0 + assertThat(result.projectedScore()).isBetween(0.0, 10.0); + assertThat(result.scoreDelta()).isEqualTo(result.projectedScore() - result.currentScore()); + assertThat(result.currentScore()).isBetween(0.0, 10.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); + } } diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index ce24ed6..4169294 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -192,6 +192,25 @@ class WeekPlanControllerTest { .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(