package com.recipeapp.planning; import com.recipeapp.auth.UserAccountRepository; import com.recipeapp.auth.entity.UserAccount; 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.planning.dto.*; import com.recipeapp.planning.entity.*; import com.recipeapp.recipe.RecipeRepository; import com.recipeapp.recipe.entity.Recipe; import com.recipeapp.recipe.entity.RecipeIngredient; import com.recipeapp.recipe.entity.Tag; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; import java.util.*; 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; private final RecipeRepository recipeRepository; private final HouseholdRepository householdRepository; private final UserAccountRepository userAccountRepository; private final VarietyScoreConfigRepository varietyScoreConfigRepository; public PlanningService(WeekPlanRepository weekPlanRepository, WeekPlanSlotRepository weekPlanSlotRepository, CookingLogRepository cookingLogRepository, RecipeRepository recipeRepository, HouseholdRepository householdRepository, UserAccountRepository userAccountRepository, VarietyScoreConfigRepository varietyScoreConfigRepository) { this.weekPlanRepository = weekPlanRepository; this.weekPlanSlotRepository = weekPlanSlotRepository; this.cookingLogRepository = cookingLogRepository; this.recipeRepository = recipeRepository; this.householdRepository = householdRepository; this.userAccountRepository = userAccountRepository; this.varietyScoreConfigRepository = varietyScoreConfigRepository; } @Transactional(readOnly = true) public WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart) { WeekPlan plan = weekPlanRepository.findByHouseholdIdAndWeekStart(householdId, weekStart) .orElseThrow(() -> new ResourceNotFoundException("Week plan not found")); return toWeekPlanResponse(plan); } @Transactional public WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart) { if (weekStart.getDayOfWeek() != DayOfWeek.MONDAY) { throw new ValidationException("weekStart must be a Monday"); } if (weekPlanRepository.existsByHouseholdIdAndWeekStart(householdId, weekStart)) { throw new ConflictException("Week plan already exists for this week"); } Household household = householdRepository.findById(householdId) .orElseThrow(() -> new ResourceNotFoundException("Household not found")); WeekPlan plan = weekPlanRepository.save(new WeekPlan(household, weekStart)); return toWeekPlanResponse(plan); } @Transactional public SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request) { WeekPlan plan = findPlan(planId, householdId); Recipe recipe = findRecipe(request.recipeId(), householdId); WeekPlanSlot slot = weekPlanSlotRepository.save( new WeekPlanSlot(plan, recipe, request.slotDate())); return toSlotResponse(slot); } @Transactional public SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request) { findPlan(planId, householdId); WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId) .orElseThrow(() -> new ResourceNotFoundException("Slot not found")); Recipe recipe = findRecipe(request.recipeId(), householdId); slot.setRecipe(recipe); return toSlotResponse(slot); } @Transactional public void deleteSlot(UUID householdId, UUID planId, UUID slotId) { findPlan(planId, householdId); WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId) .orElseThrow(() -> new ResourceNotFoundException("Slot not found")); weekPlanSlotRepository.delete(slot); } @Transactional public WeekPlanResponse confirmPlan(UUID householdId, UUID planId) { WeekPlan plan = findPlan(planId, householdId); if ("confirmed".equals(plan.getStatus())) { throw new ValidationException("Plan is already confirmed"); } if (plan.getSlots().isEmpty()) { throw new ValidationException("Plan has no slots"); } plan.setStatus("confirmed"); plan.setConfirmedAt(Instant.now()); return toWeekPlanResponse(plan); } @Transactional(readOnly = true) public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate, List tagFilters, Integer topN) { WeekPlan plan = findPlan(planId, householdId); int limit = (topN != null) ? topN : 5; if (limit <= 0) { return new SuggestionResponse(List.of()); } VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId) .orElse(VarietyScoreConfig.defaults(plan.getHousehold())); Set usedRecipeIds = plan.getSlots().stream() .map(s -> s.getRecipe().getId()) .collect(Collectors.toSet()); Set recentlyCookedIds = cookingLogRepository .findByHouseholdIdAndCookedOnAfter(householdId, plan.getWeekStart().minusDays(config.getHistoryDays())) .stream() .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); double currentScore = computeCurrentScore(plan, config, recentlyCookedIds); List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); Set lowerTagFilters = tagFilters.stream() .map(String::toLowerCase) .collect(Collectors.toSet()); List suggestions = allRecipes.stream() .filter(r -> !usedRecipeIds.contains(r.getId())) .filter(r -> matchesAllTags(r, lowerTagFilters)) .map(candidate -> { double simulatedScore = simulateVarietyScore( plan, candidate, slotDate, config, recentlyCookedIds); double scoreDelta = simulatedScore - currentScore; boolean hasConflict = scoreDelta < 0; return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict); }) .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta())) .limit(limit) .toList(); return new SuggestionResponse(suggestions); } private boolean matchesAllTags(Recipe recipe, Set lowerTagFilters) { if (lowerTagFilters.isEmpty()) return true; Set recipeTags = recipe.getTags().stream() .map(t -> t.getName().toLowerCase()) .collect(Collectors.toSet()); return recipeTags.containsAll(lowerTagFilters); } private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate, VarietyScoreConfig config, Set recentlyCookedIds) { List simulatedSlots = new ArrayList<>(); for (WeekPlanSlot slot : plan.getSlots()) { 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 recentlyCookedIds) { List 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 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 slots, VarietyScoreConfig config, Set recentlyCookedIds) { List 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> tagDays = new LinkedHashMap<>(); 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); } } } long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count(); // 2. Non-staple ingredient overlaps on consecutive days Map> ingredientDays = new LinkedHashMap<>(); for (SimulatedSlot slot : slots) { for (RecipeIngredient ri : slot.recipe.getIngredients()) { if (!ri.getIngredient().isStaple()) { ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>()) .add(slot.date); } } } long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count(); // 3. Recent repeats from cooking log long recentRepeatCount = slots.stream() .map(s -> s.recipe.getId()) .distinct() .filter(recentlyCookedIds::contains) .count(); // 4. Duplicate recipes within the plan Map 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 = MAX_VARIETY_SCORE; score -= tagRepeatCount * wTagRepeat; score -= ingredientOverlapCount * wIngredientOverlap; score -= recentRepeatCount * wRecentRepeat; score -= duplicatePenaltyCount * wPlanDuplicate; return Math.max(0, Math.min(MAX_VARIETY_SCORE, score)); } @Transactional(readOnly = true) public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) { WeekPlan plan = findPlan(planId, householdId); List slots = plan.getSlots(); if (slots.isEmpty()) { return new VarietyScoreResponse(0, List.of(), List.of(), List.of(), List.of()); } // Load config (or use defaults) VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId) .orElse(VarietyScoreConfig.defaults(plan.getHousehold())); List 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 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 TagAccumulator(tag.getTagType())) .addDay(slot.getSlotDate()); } } } List tagRepeats = tagDays.entrySet().stream() .filter(e -> hasConsecutiveDays(e.getValue().days)) .map(e -> new VarietyScoreResponse.TagRepeat( e.getKey(), e.getValue().tagType, e.getValue().days)) .toList(); // 2. Non-staple ingredient overlaps on consecutive days 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()); } } } List overlaps = ingredientDays.entrySet().stream() .filter(e -> hasConsecutiveDays(e.getValue())) .map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue())) .toList(); // 3. Recent repeats from cooking log LocalDate referenceDate = plan.getWeekStart(); List recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter( householdId, referenceDate.minusDays(historyDays)); Set recentlyCookedIds = recentLogs.stream() .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); List recentRepeats = slots.stream() .map(s -> s.getRecipe()) .filter(r -> recentlyCookedIds.contains(r.getId())) .map(Recipe::getName) .distinct() .toList(); // 4. Duplicate recipes within the plan Map recipeCounts = slots.stream() .collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting())); List duplicatesInPlan = new ArrayList<>(); long duplicatePenaltyCount = 0; for (var entry : recipeCounts.entrySet()) { if (entry.getValue() > 1) { String recipeName = slots.stream() .filter(s -> s.getRecipe().getId().equals(entry.getKey())) .findFirst() .map(s -> s.getRecipe().getName()) .orElse("Unknown"); duplicatesInPlan.add(recipeName); duplicatePenaltyCount += entry.getValue() - 1; } } // Calculate score double score = MAX_VARIETY_SCORE; score -= tagRepeats.size() * wTagRepeat; score -= overlaps.size() * wIngredientOverlap; score -= recentRepeats.size() * wRecentRepeat; score -= duplicatePenaltyCount * wPlanDuplicate; score = Math.max(0, Math.min(MAX_VARIETY_SCORE, score)); return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan); } private static class TagAccumulator { final String tagType; final List days = new ArrayList<>(); TagAccumulator(String tagType) { this.tagType = tagType; } void addDay(LocalDate day) { days.add(day); } } @Transactional public CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request) { Recipe recipe = recipeRepository.findById(request.recipeId()) .orElseThrow(() -> new ResourceNotFoundException("Recipe not found")); Household household = householdRepository.findById(householdId) .orElseThrow(() -> new ResourceNotFoundException("Household not found")); UserAccount user = userAccountRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("User not found")); LocalDate cookedOn = request.cookedOn() != null ? request.cookedOn() : LocalDate.now(); CookingLog log = cookingLogRepository.save(new CookingLog(recipe, household, cookedOn, user)); return new CookingLogResponse(log.getId(), recipe.getId(), recipe.getName(), log.getCookedOn(), user.getId()); } @Transactional(readOnly = true) public List listCookingLogs(UUID householdId, int limit, int offset) { return cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc( householdId, PageRequest.of(offset / Math.max(limit, 1), Math.max(limit, 1))) .stream() .map(cl -> new CookingLogResponse(cl.getId(), cl.getRecipe().getId(), cl.getRecipe().getName(), cl.getCookedOn(), cl.getCookedBy().getId())) .toList(); } // ── Helpers ── private WeekPlan findPlan(UUID planId, UUID householdId) { WeekPlan plan = weekPlanRepository.findById(planId) .orElseThrow(() -> new ResourceNotFoundException("Week plan not found")); if (!plan.getHousehold().getId().equals(householdId)) { throw new ResourceNotFoundException("Week plan not found"); } return plan; } private Recipe findRecipe(UUID recipeId, UUID householdId) { return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId) .orElseThrow(() -> new ResourceNotFoundException("Recipe not found")); } private WeekPlanResponse toWeekPlanResponse(WeekPlan plan) { List slots = plan.getSlots().stream() .map(this::toSlotResponse) .toList(); return new WeekPlanResponse(plan.getId(), plan.getWeekStart(), plan.getStatus(), plan.getConfirmedAt(), slots); } private SlotResponse toSlotResponse(WeekPlanSlot slot) { return new SlotResponse(slot.getId(), slot.getSlotDate(), toSlotRecipe(slot.getRecipe())); } private SlotResponse.SlotRecipe toSlotRecipe(Recipe recipe) { return new SlotResponse.SlotRecipe(recipe.getId(), recipe.getName(), recipe.getEffort(), recipe.getCookTimeMin(), recipe.getHeroImageUrl()); } private boolean hasConsecutiveDays(List days) { if (days.size() < 2) return false; List sorted = days.stream().sorted().toList(); for (int i = 1; i < sorted.size(); i++) { if (sorted.get(i).toEpochDay() - sorted.get(i - 1).toEpochDay() == 1) { return true; } } return false; } }