// LoopFollow // QuickPickMealsManager.swift import Foundation struct QuickPickMeal: Identifiable, Equatable { let id = UUID() let carbs: Double let fat: Double let protein: Double let bolus: Double static func == (lhs: QuickPickMeal, rhs: QuickPickMeal) -> Bool { lhs.id == rhs.id } } final class QuickPickMealsManager: ObservableObject { static let shared = QuickPickMealsManager() @Published private(set) var quickPickMeals: [QuickPickMeal] = [] private static let maxEntries = 500 private static let maxAgeDays = 90.0 private static let sigma: Double = 60.0 private static let halfLife: Double = 10.0 private static let minScore: Double = 0.1 private static let maxResults = 5 private init() {} // MARK: - Public API func recordMeal(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, at date: Date = Date()) { let entry = RemoteMealHistoryEntry(carbs: carbs, fat: fat, protein: protein, bolus: bolus, date: date) var history = Storage.shared.remoteMealHistory.value history.append(entry) history = Self.pruned(history, now: date) Storage.shared.remoteMealHistory.value = history } func refresh(now: Date = Date(), carbStep: Double = 1.0, maxCarbs: Double, includeFatProtein: Bool) { let history = Storage.shared.remoteMealHistory.value quickPickMeals = Self.computeQuickPickMeals( from: history, now: now, carbStep: carbStep, maxCarbs: maxCarbs, includeFatProtein: includeFatProtein ) } // MARK: - Scoring (static for testability) static func computeQuickPickMeals( from history: [RemoteMealHistoryEntry], now: Date, carbStep: Double, maxCarbs: Double, includeFatProtein: Bool ) -> [QuickPickMeal] { guard carbStep > 0 else { return [] } let nowMinute = { let cal = Calendar.current return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now) }() let nowDOW = Calendar.current.component(.weekday, from: now) // Group by rounded carbs; track score + best entry (highest scored) for fat/protein var groupScores: [Double: Double] = [:] var groupBestEntry: [Double: (entry: RemoteMealHistoryEntry, score: Double)] = [:] for entry in history { let rounded = (entry.carbs / carbStep).rounded() * carbStep guard rounded > 0, rounded <= maxCarbs else { continue } let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute) let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW) let daysAgo = now.timeIntervalSince(entry.date) / 86400.0 let r = recencyScore(daysAgo: daysAgo) let score = t * d * r groupScores[rounded, default: 0] += score if let current = groupBestEntry[rounded] { if score > current.score { groupBestEntry[rounded] = (entry, score) } } else { groupBestEntry[rounded] = (entry, score) } } return groupScores .filter { $0.value >= minScore } .sorted { $0.value > $1.value } .prefix(maxResults) .map { item in let best = groupBestEntry[item.key]?.entry return QuickPickMeal( carbs: item.key, fat: includeFatProtein ? (best?.fat ?? 0) : 0, protein: includeFatProtein ? (best?.protein ?? 0) : 0, bolus: best?.bolus ?? 0 ) } } static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double { let diff = abs(entryMinute - nowMinute) let circularDiff = Double(min(diff, 1440 - diff)) return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma)) } static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double { if entryDOW == nowDOW { return 1.0 } let nowWeekend = nowDOW == 1 || nowDOW == 7 let entryWeekend = entryDOW == 1 || entryDOW == 7 if nowWeekend == entryWeekend { return 0.7 } return 0.15 } static func recencyScore(daysAgo: Double) -> Double { pow(0.5, daysAgo / halfLife) } // MARK: - Helpers private static func pruned(_ history: [RemoteMealHistoryEntry], now: Date) -> [RemoteMealHistoryEntry] { let cutoff = now.addingTimeInterval(-maxAgeDays * 86400) var filtered = history.filter { $0.date > cutoff } if filtered.count > maxEntries { filtered.sort { $0.date > $1.date } filtered = Array(filtered.prefix(maxEntries)) } return filtered } }