diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index a65e78d..2c8b149 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -15,7 +15,7 @@ struct AutocompleteEngine { let dishName: String } - // recentPlans: up to 4 previous weeks (sorted newest-first) for historical scoring. + // recentPlans: up to 12 previous weeks for period detection (weekly/bi-weekly/monthly cycles). static func autocomplete( emptySlots: [MealSlot], currentPlan: WeekPlan, @@ -25,7 +25,7 @@ struct AutocompleteEngine { ) -> AutocompleteResult { let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) }) let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) - let historyScores = historicalScores(dishes: allDishes, recentPlans: recentPlans) + let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate) var filledSlots: [(slotId: UUID, dishId: UUID)] = [] var unfilledCount = 0 @@ -181,31 +181,73 @@ struct AutocompleteEngine { return result } - // MARK: - Historical scoring + // MARK: - Period-based scoring - // Returns a recency-penalty multiplier [0.0, 1.0] per dish. - // Dishes used more recently get a lower multiplier (deprioritised). - private static func historicalScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] { + // Weight multiplier per dish based on its detected recurrence cycle. + // Values > 1.0 boost a dish that is "due"; < 1.0 suppress one used too recently. + // Requires recentPlans covering ≥ 8–12 weeks to reliably detect monthly cycles. + private static func periodScores( + dishes: [Dish], + recentPlans: [WeekPlan], + currentWeekStart: Date + ) -> [UUID: Double] { guard !recentPlans.isEmpty else { return [:] } - let sorted = recentPlans.sorted { $0.weekStartDate > $1.weekStartDate } + let calendar = Calendar.current var scores: [UUID: Double] = [:] + for dish in dishes { - for (weekIndex, plan) in sorted.enumerated() { + // Week offsets: how many weeks ago did this dish appear? + var offsets: [Int] = [] + for plan in recentPlans { guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue } - let multiplier: Double - switch weekIndex { - case 0: multiplier = 0.20 // used last week — strongly deprioritise - case 1: multiplier = 0.50 // 2 weeks ago - case 2: multiplier = 0.75 // 3 weeks ago - default: multiplier = 0.90 - } - scores[dish.id] = multiplier - break // only the most recent appearance matters + let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0 + if diff > 0 { offsets.append(diff) } } + guard !offsets.isEmpty else { continue } + + let weeksSinceLast = offsets.min()! + + guard offsets.count >= 2 else { + // Single sighting: simple recency penalty, no cycle to detect + switch weeksSinceLast { + case 1: scores[dish.id] = 0.20 + case 2: scores[dish.id] = 0.50 + case 3: scores[dish.id] = 0.75 + default: break // ≥4 weeks ago → neutral (1.0) + } + continue + } + + // Estimate recurrence period via median of consecutive intervals + let sorted = offsets.sorted() + var intervals: [Int] = [] + for i in 0.. Double { + let s = values.sorted() + let n = s.count + return n % 2 == 1 ? Double(s[n / 2]) : Double(s[n / 2 - 1] + s[n / 2]) / 2.0 + } + // MARK: - Weighted pick private static func weightedRandomPick( diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 6356df6..19c2e9a 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -120,7 +120,7 @@ final class HomeViewModel: ObservableObject { let recentPlans = allPlans .filter { $0.weekStartDate < plan.weekStartDate } .sorted { $0.weekStartDate > $1.weekStartDate } - .prefix(4) + .prefix(12) let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut } let result = AutocompleteEngine.autocomplete(