Files
FamilyMealPlanner/MealMood/Services/AutocompleteEngine.swift
T
alexandrev-tibco e593453abd Version casi lista
2026-02-17 13:34:02 +01:00

197 lines
6.6 KiB
Swift

import Foundation
struct AutocompleteEngine {
struct AutocompleteResult {
let filledSlots: [(slotId: UUID, dishId: UUID)]
let unfilledCount: Int
}
static func autocomplete(
emptySlots: [MealSlot],
currentPlan: WeekPlan,
allDishes: [Dish],
allTags: [Tag]
) -> AutocompleteResult {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
var unfilledCount = 0
let sortedEmpty = emptySlots.sorted {
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
}
for slot in sortedEmpty {
let strictCandidates = allDishes.filter { dish in
!violatesRules(
dish: dish,
slot: slot,
plan: currentPlan,
tagMap: tagMap,
dishMap: dishMap
)
}
// Fallback: if strict rules cannot fill a slot, allow repeating dishes
// that only fail the implicit "no repeat this week" rule.
let candidates: [Dish]
if strictCandidates.isEmpty {
candidates = allDishes.filter { dish in
!violatesExplicitRules(
dish: dish,
slot: slot,
plan: currentPlan,
tagMap: tagMap,
dishMap: dishMap
)
}
} else {
candidates = strictCandidates
}
if candidates.isEmpty {
unfilledCount += 1
continue
}
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan) {
slot.dishId = picked.id
filledSlots.append((slotId: slot.id, dishId: picked.id))
} else {
unfilledCount += 1
}
}
return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount)
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
allTags: [Tag],
allDishes: [Dish]
) -> Bool {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
private static func violatesRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) ||
violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
private static func violatesDefaultNoRepeatRule(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag]
) -> Bool {
// Default behavior: if a dish has no explicit rules, avoid repeating it in the same week.
if !dishHasExplicitRules(dish: dish, tagMap: tagMap) {
let alreadyAssignedThisWeek = plan.slots.contains {
$0.id != slot.id && $0.dishId == dish.id
}
if alreadyAssignedThisWeek { return true }
}
return false
}
private static func violatesExplicitRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
for tagId in dish.tagIds {
guard let tag = tagMap[tagId] else { continue }
// Max per week
if let maxPerWeek = tag.maxPerWeek {
var count = 0
for s in plan.slots {
guard let did = s.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { count += 1 }
}
if count >= maxPerWeek { return true }
}
// No consecutive
if tag.noConsecutive {
for adjSlot in adjacentSlots(of: slot, in: plan) {
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
// No duplicate in day
if tag.noDuplicateInDay {
for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
// Meal type restriction
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
return true
}
}
return false
}
private static func dishHasExplicitRules(dish: Dish, tagMap: [UUID: Tag]) -> Bool {
for tagId in dish.tagIds {
guard let tag = tagMap[tagId] else { continue }
if tag.maxPerWeek != nil ||
tag.noConsecutive ||
tag.noDuplicateInDay ||
tag.mealTypeRestriction != nil {
return true
}
}
return false
}
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
var result: [MealSlot] = []
if slot.dayOfWeek > 0,
let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
result.append(prev)
}
if slot.dayOfWeek < 6,
let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
result.append(next)
}
return result
}
private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? {
guard !candidates.isEmpty else { return nil }
let weights: [Double] = candidates.map { dish in
let usage = plan.slots.filter { $0.dishId == dish.id }.count
switch usage {
case 0: return 3.0
case 1: return 2.0
default: return 1.0
}
}
let total = weights.reduce(0, +)
var r = Double.random(in: 0..<total)
for (i, w) in weights.enumerated() {
r -= w
if r <= 0 { return candidates[i] }
}
return candidates.last
}
}