MealStatsSetup.swift 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import CoreData
  2. import Foundation
  3. /// Represents statistical data about meal macronutrients for a specific day
  4. struct MealStats: Identifiable {
  5. let id = UUID()
  6. /// The date representing this time period
  7. let date: Date
  8. /// Total carbohydrates in grams
  9. let carbs: Double
  10. /// Total fat in grams
  11. let fat: Double
  12. /// Total protein in grams
  13. let protein: Double
  14. }
  15. extension Stat.StateModel {
  16. /// Sets up meal statistics by fetching and processing meal data
  17. ///
  18. /// This function:
  19. /// 1. Fetches hourly and daily meal statistics asynchronously
  20. /// 2. Updates the state model with the fetched statistics on the main actor
  21. /// 3. Calculates and caches initial daily averages
  22. func setupMealStats() {
  23. Task {
  24. let (hourly, daily) = await fetchMealStats()
  25. await MainActor.run {
  26. self.hourlyMealStats = hourly
  27. self.dailyMealStats = daily
  28. }
  29. // Initially calculate and cache daily averages
  30. await calculateAndCacheDailyAverages()
  31. }
  32. }
  33. /// Fetches and processes meal statistics from Core Data
  34. /// - Returns: A tuple containing hourly and daily meal statistics arrays
  35. ///
  36. /// This function:
  37. /// 1. Fetches carbohydrate entries from Core Data
  38. /// 2. Groups entries by hour and day
  39. /// 3. Calculates total macronutrients for each time period
  40. /// 4. Returns the processed statistics as (hourly: [MealStats], daily: [MealStats])
  41. private func fetchMealStats() async -> (hourly: [MealStats], daily: [MealStats]) {
  42. // Fetch CarbEntryStored entries from Core Data
  43. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  44. ofType: CarbEntryStored.self,
  45. onContext: mealTaskContext,
  46. predicate: NSPredicate.carbsForStats,
  47. key: "date",
  48. ascending: true,
  49. batchSize: 100
  50. )
  51. return await mealTaskContext.perform {
  52. // Safely unwrap the fetched results, return empty arrays if nil
  53. guard let fetchedResults = results as? [CarbEntryStored] else { return ([], []) }
  54. let calendar = Calendar.current
  55. // Group entries by hour for hourly statistics
  56. // TODO: - Introduce paging to also be able to show complete history
  57. let now = Date()
  58. let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: now) ?? now
  59. let hourlyGrouped = Dictionary(grouping: fetchedResults.filter { entry in
  60. guard let date = entry.date else { return false }
  61. return date >= twentyDaysAgo && date <= now
  62. }) { entry in
  63. let components = calendar.dateComponents([.year, .month, .day, .hour], from: entry.date ?? Date())
  64. return calendar.date(from: components) ?? Date()
  65. }
  66. // Group entries by day for daily statistics
  67. let dailyGrouped = Dictionary(grouping: fetchedResults) { entry in
  68. calendar.startOfDay(for: entry.date ?? Date())
  69. }
  70. // Calculate statistics for each hour
  71. let hourlyStats = hourlyGrouped.keys.sorted().map { timePoint in
  72. let entries = hourlyGrouped[timePoint, default: []]
  73. return MealStats(
  74. date: timePoint,
  75. carbs: entries.reduce(0.0) { $0 + $1.carbs },
  76. fat: entries.reduce(0.0) { $0 + $1.fat },
  77. protein: entries.reduce(0.0) { $0 + $1.protein }
  78. )
  79. }
  80. // Calculate statistics for each day
  81. let dailyStats = dailyGrouped.keys.sorted().map { timePoint in
  82. let entries = dailyGrouped[timePoint, default: []]
  83. return MealStats(
  84. date: timePoint,
  85. carbs: entries.reduce(0.0) { $0 + $1.carbs },
  86. fat: entries.reduce(0.0) { $0 + $1.fat },
  87. protein: entries.reduce(0.0) { $0 + $1.protein }
  88. )
  89. }
  90. return (hourlyStats, dailyStats)
  91. }
  92. }
  93. /// Calculates and caches the daily averages of macronutrients
  94. ///
  95. /// This function:
  96. /// 1. Groups meal statistics by day
  97. /// 2. Calculates average carbs, fat and protein for each day
  98. /// 3. Caches the results for later use
  99. ///
  100. /// This only needs to be called once during subscribe.
  101. private func calculateAndCacheDailyAverages() async {
  102. let calendar = Calendar.current
  103. // Calculate averages in context
  104. let dailyAverages = await mealTaskContext.perform { [dailyMealStats] in
  105. // Group by days
  106. let groupedByDay = Dictionary(grouping: dailyMealStats) { stat in
  107. calendar.startOfDay(for: stat.date)
  108. }
  109. // Calculate averages for each day
  110. var averages: [Date: (Double, Double, Double)] = [:]
  111. for (day, stats) in groupedByDay {
  112. let total = stats.reduce((0.0, 0.0, 0.0)) { acc, stat in
  113. (acc.0 + stat.carbs, acc.1 + stat.fat, acc.2 + stat.protein)
  114. }
  115. let count = Double(stats.count)
  116. averages[day] = (total.0 / count, total.1 / count, total.2 / count)
  117. }
  118. return averages
  119. }
  120. // Update cache on main thread
  121. await MainActor.run {
  122. self.dailyAveragesCache = dailyAverages
  123. }
  124. }
  125. /// Returns the average macronutrient values for the given date range from the cache
  126. /// - Parameter range: A tuple containing the start and end dates to get averages for
  127. /// - Returns: A tuple containing the average carbs, fat and protein values for the date range
  128. func getCachedMealAverages(for range: (start: Date, end: Date)) -> (carbs: Double, fat: Double, protein: Double) {
  129. return calculateAveragesForDateRange(from: range.start, to: range.end)
  130. }
  131. /// Calculates the average macronutrient values for a given date range
  132. /// - Parameters:
  133. /// - startDate: The start date of the range to calculate averages for
  134. /// - endDate: The end date of the range to calculate averages for
  135. /// - Returns: A tuple containing the average carbs, fat and protein values for the date range
  136. func calculateAveragesForDateRange(from startDate: Date, to endDate: Date) -> (carbs: Double, fat: Double, protein: Double) {
  137. // Filter cached values to only include those within the date range
  138. let relevantStats = dailyAveragesCache.filter { date, _ in
  139. date >= startDate && date <= endDate
  140. }
  141. // Return zeros if no data exists for the range
  142. guard !relevantStats.isEmpty else { return (0, 0, 0) }
  143. // Calculate total macronutrients across all days
  144. let total = relevantStats.values.reduce((0.0, 0.0, 0.0)) { acc, avg in
  145. (acc.0 + avg.0, acc.1 + avg.1, acc.2 + avg.2)
  146. }
  147. // Calculate averages by dividing totals by number of days
  148. let count = Double(relevantStats.count)
  149. return (total.0 / count, total.1 / count, total.2 / count)
  150. }
  151. }