1.1.0: Smarter autocomplete + Statistics view
- AutocompleteEngine: MRV heuristic picks the most-constrained slot first at each step; historical scoring deprioritises dishes used in recent weeks (decay: 0.20 last week → 0.90 four+ weeks ago) - HomeViewModel: passes last 4 weeks as recentPlans to autocomplete - StatsView (Premium): streak, weeks planned, completion rate, top 8 dishes with bar charts, top 6 tags with colour bars — 6 languages - StoreManager: update product ID to approved bundle-ID convention - Version bump to 1.1.3 / build 48 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,44 +15,44 @@ struct AutocompleteEngine {
|
||||
let dishName: String
|
||||
}
|
||||
|
||||
// recentPlans: up to 4 previous weeks (sorted newest-first) for historical scoring.
|
||||
static func autocomplete(
|
||||
emptySlots: [MealSlot],
|
||||
currentPlan: WeekPlan,
|
||||
allDishes: [Dish],
|
||||
allTags: [Tag]
|
||||
allTags: [Tag],
|
||||
recentPlans: [WeekPlan] = []
|
||||
) -> 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)
|
||||
|
||||
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
||||
var unfilledCount = 0
|
||||
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
|
||||
|
||||
let sortedEmpty = emptySlots
|
||||
.filter { !$0.isEatingOut }
|
||||
.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) }
|
||||
// MRV: at each step pick the slot with fewest valid candidates first.
|
||||
// Assignments update currentPlan.slots in-place, so subsequent candidate
|
||||
// counts automatically reflect the growing set of committed dishes.
|
||||
while !pendingSlots.isEmpty {
|
||||
// Score each remaining slot by strict candidate count
|
||||
let ranked = pendingSlots
|
||||
.map { slot -> (slot: MealSlot, strict: [Dish]) in
|
||||
let strict = allDishes.filter {
|
||||
!violatesRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
return (slot, strict)
|
||||
}
|
||||
.sorted { $0.strict.count < $1.strict.count }
|
||||
|
||||
for slot in sortedEmpty {
|
||||
let strictCandidates = allDishes.filter { dish in
|
||||
!violatesRules(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: currentPlan,
|
||||
tagMap: tagMap,
|
||||
dishMap: dishMap
|
||||
)
|
||||
}
|
||||
let (slot, strictCandidates) = ranked[0]
|
||||
pendingSlots.removeAll { $0.id == slot.id }
|
||||
|
||||
// Fallback: if strict rules cannot fill a slot, allow repeating dishes
|
||||
// that only fail the implicit "no repeat this week" rule.
|
||||
// Fallback: relax the implicit no-repeat rule if strict set is empty
|
||||
let candidates: [Dish]
|
||||
if strictCandidates.isEmpty {
|
||||
candidates = allDishes.filter { dish in
|
||||
!violatesExplicitRules(
|
||||
dish: dish,
|
||||
slot: slot,
|
||||
plan: currentPlan,
|
||||
tagMap: tagMap,
|
||||
dishMap: dishMap
|
||||
)
|
||||
candidates = allDishes.filter {
|
||||
!violatesExplicitRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
} else {
|
||||
candidates = strictCandidates
|
||||
@@ -63,7 +63,7 @@ struct AutocompleteEngine {
|
||||
continue
|
||||
}
|
||||
|
||||
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan) {
|
||||
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores) {
|
||||
slot.dishId = picked.id
|
||||
filledSlots.append((slotId: slot.id, dishId: picked.id))
|
||||
} else {
|
||||
@@ -103,6 +103,8 @@ struct AutocompleteEngine {
|
||||
return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
||||
}
|
||||
|
||||
// MARK: - Rule checks
|
||||
|
||||
private static func violatesRules(
|
||||
dish: Dish,
|
||||
slot: MealSlot,
|
||||
@@ -110,20 +112,12 @@ struct AutocompleteEngine {
|
||||
tagMap: [UUID: Tag],
|
||||
dishMap: [UUID: Dish]
|
||||
) -> Bool {
|
||||
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) ||
|
||||
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan) ||
|
||||
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: avoid assigning the same dish twice in the same week,
|
||||
// regardless of whether it has explicit tag rules. The fallback path in
|
||||
// autocomplete() still allows repeating when no alternative exists.
|
||||
return plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
|
||||
private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool {
|
||||
plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
private static func violatesExplicitRules(
|
||||
@@ -136,7 +130,6 @@ struct AutocompleteEngine {
|
||||
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 {
|
||||
@@ -146,7 +139,6 @@ struct AutocompleteEngine {
|
||||
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 }
|
||||
@@ -154,7 +146,6 @@ struct AutocompleteEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -162,12 +153,10 @@ struct AutocompleteEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Meal type restriction
|
||||
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
|
||||
return true
|
||||
}
|
||||
|
||||
// Day restriction
|
||||
if let dayRestriction = tag.dayRestriction {
|
||||
switch dayRestriction {
|
||||
case "weekdays" where slot.dayOfWeek > 4: return true
|
||||
@@ -192,16 +181,50 @@ struct AutocompleteEngine {
|
||||
return result
|
||||
}
|
||||
|
||||
private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? {
|
||||
// MARK: - Historical 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] {
|
||||
guard !recentPlans.isEmpty else { return [:] }
|
||||
let sorted = recentPlans.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||
var scores: [UUID: Double] = [:]
|
||||
for dish in dishes {
|
||||
for (weekIndex, plan) in sorted.enumerated() {
|
||||
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
|
||||
}
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
// MARK: - Weighted pick
|
||||
|
||||
private static func weightedRandomPick(
|
||||
candidates: [Dish],
|
||||
plan: WeekPlan,
|
||||
historyScores: [UUID: Double] = [:]
|
||||
) -> Dish? {
|
||||
guard !candidates.isEmpty else { return nil }
|
||||
let weights: [Double] = candidates.map { dish in
|
||||
let usage = plan.slots.filter { $0.dishId == dish.id }.count
|
||||
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
|
||||
let recencyMultiplier = historyScores[dish.id] ?? 1.0
|
||||
let baseWeight: Double
|
||||
switch usage {
|
||||
case 0: return 3.0 * priorityBoost
|
||||
case 1: return 2.0 * priorityBoost
|
||||
default: return 1.0 * priorityBoost
|
||||
case 0: baseWeight = 3.0
|
||||
case 1: baseWeight = 2.0
|
||||
default: baseWeight = 1.0
|
||||
}
|
||||
return baseWeight * priorityBoost * recencyMultiplier
|
||||
}
|
||||
let total = weights.reduce(0, +)
|
||||
var r = Double.random(in: 0..<total)
|
||||
|
||||
Reference in New Issue
Block a user