feat: Add-to-Plan flows C4/C5/C6 — recipe picker, quick actions, day picker #44

Merged
marcel merged 13 commits from feat/issue-42-add-to-plan into master 2026-04-09 09:53:08 +02:00
5 changed files with 170 additions and 0 deletions
Showing only changes of commit 7175b56833 - Show all commits

View File

@@ -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<UUID> 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<UUID> recentlyCookedIds) {
List<WeekPlanSlot> slots = plan.getSlots();
if (slots.isEmpty()) return 10.0;
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();
Map<String, List<LocalDate>> 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<String, List<LocalDate>> 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<UUID, Long> 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);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,7 @@
package com.recipeapp.planning.dto;
public record VarietyPreviewResponse(
double currentScore,
double projectedScore,
double scoreDelta
) {}

View File

@@ -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);
}
}

View File

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