QuickPickMealsManager.swift 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. // LoopFollow
  2. // QuickPickMealsManager.swift
  3. import Foundation
  4. struct QuickPickMeal: Identifiable, Equatable {
  5. let id = UUID()
  6. let carbs: Double
  7. let fat: Double
  8. let protein: Double
  9. let bolus: Double
  10. static func == (lhs: QuickPickMeal, rhs: QuickPickMeal) -> Bool {
  11. lhs.id == rhs.id
  12. }
  13. }
  14. final class QuickPickMealsManager: ObservableObject {
  15. static let shared = QuickPickMealsManager()
  16. @Published private(set) var quickPickMeals: [QuickPickMeal] = []
  17. private static let maxEntries = 500
  18. private static let maxAgeDays = 90.0
  19. private static let sigma: Double = 60.0
  20. private static let halfLife: Double = 10.0
  21. private static let minScore: Double = 0.1
  22. private static let maxResults = 5
  23. private init() {}
  24. // MARK: - Public API
  25. func recordMeal(carbs: Double, fat: Double = 0, protein: Double = 0, bolus: Double = 0, at date: Date = Date()) {
  26. let entry = RemoteMealHistoryEntry(carbs: carbs, fat: fat, protein: protein, bolus: bolus, date: date)
  27. var history = Storage.shared.remoteMealHistory.value
  28. history.append(entry)
  29. history = Self.pruned(history, now: date)
  30. Storage.shared.remoteMealHistory.value = history
  31. }
  32. func refresh(now: Date = Date(), carbStep: Double = 1.0, maxCarbs: Double, includeFatProtein: Bool) {
  33. let history = Storage.shared.remoteMealHistory.value
  34. quickPickMeals = Self.computeQuickPickMeals(
  35. from: history,
  36. now: now,
  37. carbStep: carbStep,
  38. maxCarbs: maxCarbs,
  39. includeFatProtein: includeFatProtein
  40. )
  41. }
  42. // MARK: - Scoring (static for testability)
  43. static func computeQuickPickMeals(
  44. from history: [RemoteMealHistoryEntry],
  45. now: Date,
  46. carbStep: Double,
  47. maxCarbs: Double,
  48. includeFatProtein: Bool
  49. ) -> [QuickPickMeal] {
  50. guard carbStep > 0 else { return [] }
  51. let nowMinute = {
  52. let cal = Calendar.current
  53. return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
  54. }()
  55. let nowDOW = Calendar.current.component(.weekday, from: now)
  56. // Group by rounded carbs; track score + best entry (highest scored) for fat/protein
  57. var groupScores: [Double: Double] = [:]
  58. var groupBestEntry: [Double: (entry: RemoteMealHistoryEntry, score: Double)] = [:]
  59. for entry in history {
  60. let rounded = (entry.carbs / carbStep).rounded() * carbStep
  61. guard rounded > 0, rounded <= maxCarbs else { continue }
  62. let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute)
  63. let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW)
  64. let daysAgo = now.timeIntervalSince(entry.date) / 86400.0
  65. let r = recencyScore(daysAgo: daysAgo)
  66. let score = t * d * r
  67. groupScores[rounded, default: 0] += score
  68. if let current = groupBestEntry[rounded] {
  69. if score > current.score {
  70. groupBestEntry[rounded] = (entry, score)
  71. }
  72. } else {
  73. groupBestEntry[rounded] = (entry, score)
  74. }
  75. }
  76. return groupScores
  77. .filter { $0.value >= minScore }
  78. .sorted { $0.value > $1.value }
  79. .prefix(maxResults)
  80. .map { item in
  81. let best = groupBestEntry[item.key]?.entry
  82. return QuickPickMeal(
  83. carbs: item.key,
  84. fat: includeFatProtein ? (best?.fat ?? 0) : 0,
  85. protein: includeFatProtein ? (best?.protein ?? 0) : 0,
  86. bolus: best?.bolus ?? 0
  87. )
  88. }
  89. }
  90. static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double {
  91. let diff = abs(entryMinute - nowMinute)
  92. let circularDiff = Double(min(diff, 1440 - diff))
  93. return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma))
  94. }
  95. static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double {
  96. if entryDOW == nowDOW { return 1.0 }
  97. let nowWeekend = nowDOW == 1 || nowDOW == 7
  98. let entryWeekend = entryDOW == 1 || entryDOW == 7
  99. if nowWeekend == entryWeekend { return 0.7 }
  100. return 0.15
  101. }
  102. static func recencyScore(daysAgo: Double) -> Double {
  103. pow(0.5, daysAgo / halfLife)
  104. }
  105. // MARK: - Helpers
  106. private static func pruned(_ history: [RemoteMealHistoryEntry], now: Date) -> [RemoteMealHistoryEntry] {
  107. let cutoff = now.addingTimeInterval(-maxAgeDays * 86400)
  108. var filtered = history.filter { $0.date > cutoff }
  109. if filtered.count > maxEntries {
  110. filtered.sort { $0.date > $1.date }
  111. filtered = Array(filtered.prefix(maxEntries))
  112. }
  113. return filtered
  114. }
  115. }