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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user