BolusStatsSetup.swift 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import CoreData
  2. import Foundation
  3. /// Represents statistical data about bolus insulin for a specific time period
  4. struct BolusStats: Identifiable {
  5. let id = UUID()
  6. /// The date representing this time period
  7. let date: Date
  8. /// Total manual bolus insulin in units
  9. let manualBolus: Double
  10. /// Total SMB insulin in units
  11. let smb: Double
  12. /// Total external bolus insulin in units
  13. let external: Double
  14. }
  15. extension Stat.StateModel {
  16. /// Sets up bolus statistics by fetching and processing bolus data
  17. ///
  18. /// This function:
  19. /// 1. Fetches hourly and daily bolus 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 setupBolusStats() {
  23. Task {
  24. let (hourly, daily) = await fetchBolusStats()
  25. await MainActor.run {
  26. self.hourlyBolusStats = hourly
  27. self.dailyBolusStats = daily
  28. }
  29. // Initially calculate and cache daily averages
  30. await calculateAndCacheBolusAverages()
  31. }
  32. }
  33. /// Fetches and processes bolus statistics from Core Data
  34. /// - Returns: A tuple containing hourly and daily bolus statistics arrays
  35. ///
  36. /// This function:
  37. /// 1. Fetches bolus entries from Core Data
  38. /// 2. Groups entries by hour and day
  39. /// 3. Calculates total insulin for each time period
  40. /// 4. Returns the processed statistics as (hourly: [BolusStats], daily: [BolusStats])
  41. private func fetchBolusStats() async -> (hourly: [BolusStats], daily: [BolusStats]) {
  42. // Fetch PumpEventStored entries from Core Data
  43. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  44. ofType: BolusStored.self,
  45. onContext: bolusTaskContext,
  46. predicate: NSPredicate.pumpHistoryForStats,
  47. key: "pumpEvent.timestamp",
  48. ascending: true,
  49. batchSize: 100
  50. )
  51. // Variables to hold the results
  52. var hourlyStats: [BolusStats] = []
  53. var dailyStats: [BolusStats] = []
  54. // Process CoreData results within the context's thread
  55. await bolusTaskContext.perform {
  56. guard let fetchedResults = results as? [BolusStored] else {
  57. return
  58. }
  59. let calendar = Calendar.current
  60. // Group entries by hour for hourly statistics
  61. // TODO: - Introduce paging to also be able to show complete history
  62. let now = Date()
  63. let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: now) ?? now
  64. let hourlyGrouped = Dictionary(grouping: fetchedResults.filter { entry in
  65. guard let date = entry.pumpEvent?.timestamp else { return false }
  66. return date >= twentyDaysAgo && date <= now
  67. }) { entry in
  68. let components = calendar.dateComponents(
  69. [.year, .month, .day, .hour],
  70. from: entry.pumpEvent?.timestamp ?? Date()
  71. )
  72. return calendar.date(from: components) ?? Date()
  73. }
  74. // Group entries by day for daily statistics
  75. let dailyGrouped = Dictionary(grouping: fetchedResults) { entry in
  76. calendar.startOfDay(for: entry.pumpEvent?.timestamp ?? Date())
  77. }
  78. // Process hourly stats
  79. hourlyStats = hourlyGrouped.keys.sorted().map { timePoint in
  80. let entries = hourlyGrouped[timePoint, default: []]
  81. return BolusStats(
  82. date: timePoint,
  83. manualBolus: entries.reduce(0.0) { sum, entry in
  84. if !entry.isSMB, !entry.isExternal {
  85. return sum + (entry.amount?.doubleValue ?? 0)
  86. }
  87. return sum
  88. },
  89. smb: entries.reduce(0.0) { sum, entry in
  90. if entry.isSMB {
  91. return sum + (entry.amount?.doubleValue ?? 0)
  92. }
  93. return sum
  94. },
  95. external: entries.reduce(0.0) { sum, entry in
  96. if entry.isExternal {
  97. return sum + (entry.amount?.doubleValue ?? 0)
  98. }
  99. return sum
  100. }
  101. )
  102. }
  103. // Process daily stats
  104. dailyStats = dailyGrouped.keys.sorted().map { timePoint in
  105. let entries = dailyGrouped[timePoint, default: []]
  106. return BolusStats(
  107. date: timePoint,
  108. manualBolus: entries.reduce(0.0) { sum, entry in
  109. if !entry.isSMB, !entry.isExternal {
  110. return sum + (entry.amount?.doubleValue ?? 0)
  111. }
  112. return sum
  113. },
  114. smb: entries.reduce(0.0) { sum, entry in
  115. if entry.isSMB {
  116. return sum + (entry.amount?.doubleValue ?? 0)
  117. }
  118. return sum
  119. },
  120. external: entries.reduce(0.0) { sum, entry in
  121. if entry.isExternal {
  122. return sum + (entry.amount?.doubleValue ?? 0)
  123. }
  124. return sum
  125. }
  126. )
  127. }
  128. }
  129. return (hourlyStats, dailyStats)
  130. }
  131. /// Calculates and caches the daily averages of bolus insulin
  132. ///
  133. /// This function:
  134. /// 1. Groups bolus statistics by day
  135. /// 2. Calculates average total, carb and correction bolus for each day
  136. /// 3. Caches the results for later use
  137. ///
  138. /// This only needs to be called once during subscribe.
  139. private func calculateAndCacheBolusAverages() async {
  140. let calendar = Calendar.current
  141. // Calculate averages in context
  142. let dailyAverages = await bolusTaskContext.perform { [dailyBolusStats] in
  143. // Group by days
  144. let groupedByDay = Dictionary(grouping: dailyBolusStats) { stat in
  145. calendar.startOfDay(for: stat.date)
  146. }
  147. // Calculate averages for each day
  148. var averages: [Date: (Double, Double, Double)] = [:]
  149. for (day, stats) in groupedByDay {
  150. let total = stats.reduce((0.0, 0.0, 0.0)) { acc, stat in
  151. (acc.0 + stat.manualBolus, acc.1 + stat.smb, acc.2 + stat.external)
  152. }
  153. let count = Double(stats.count)
  154. averages[day] = (total.0 / count, total.1 / count, total.2 / count)
  155. }
  156. return averages
  157. }
  158. // Update cache on main thread
  159. await MainActor.run {
  160. self.bolusAveragesCache = dailyAverages
  161. }
  162. }
  163. /// Returns the average bolus values for the given date range from the cache
  164. /// - Parameter range: A tuple containing the start and end dates to get averages for
  165. /// - Returns: A tuple containing the average total, carb and correction bolus values for the date range
  166. func getCachedBolusAverages(for range: (start: Date, end: Date)) -> (manual: Double, smb: Double, external: Double) {
  167. return calculateBolusAveragesForDateRange(from: range.start, to: range.end)
  168. }
  169. /// Calculates the average bolus values for a given date range
  170. /// - Parameters:
  171. /// - startDate: The start date of the range to calculate averages for
  172. /// - endDate: The end date of the range to calculate averages for
  173. /// - Returns: A tuple containing the average total, carb and correction bolus values for the date range
  174. func calculateBolusAveragesForDateRange(
  175. from startDate: Date,
  176. to endDate: Date
  177. ) -> (manual: Double, smb: Double, external: Double) {
  178. // Filter cached values to only include those within the date range
  179. let relevantStats = bolusAveragesCache.filter { date, _ in
  180. date >= startDate && date <= endDate
  181. }
  182. // Return zeros if no data exists for the range
  183. guard !relevantStats.isEmpty else { return (0, 0, 0) }
  184. // Calculate total bolus across all days
  185. let total = relevantStats.values.reduce((0.0, 0.0, 0.0)) { acc, avg in
  186. (acc.0 + avg.0, acc.1 + avg.1, acc.2 + avg.2)
  187. }
  188. // Calculate averages by dividing totals by number of days
  189. let count = Double(relevantStats.count)
  190. return (total.0 / count, total.1 / count, total.2 / count)
  191. }
  192. }
  193. /// Extension to convert Decimal to Double
  194. private extension Decimal {
  195. var doubleValue: Double {
  196. NSDecimalNumber(decimal: self).doubleValue
  197. }
  198. }