| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140 |
- // 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
- }
- }
|