StatStateModel.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import CoreData
  2. import Foundation
  3. import Observation
  4. import SwiftUI
  5. import Swinject
  6. extension Stat {
  7. /// Defines the available types of glucose charts
  8. enum GlucoseChartType: String, CaseIterable {
  9. /// Ambulatory Glucose Profile showing percentile ranges
  10. case percentile = "Percentile"
  11. /// Time-based distribution of glucose ranges
  12. case distribution = "Distribution"
  13. }
  14. /// Defines the available types of insulin charts
  15. enum InsulinChartType: String, CaseIterable {
  16. /// Shows total daily insulin doses
  17. case totalDailyDose = "Total Daily Dose"
  18. /// Shows distribution of bolus types
  19. case bolusDistribution = "Bolus Distribution"
  20. }
  21. /// Defines the available types of looping charts
  22. enum LoopingChartType: String, CaseIterable {
  23. /// Shows loop completion and success rates
  24. case loopingPerformance = "Looping Performance"
  25. /// Shows CGM connection status over time
  26. case cgmConnectionTrace = "CGM Connection Trace"
  27. /// Shows Trio pump uptime statistics
  28. case trioUpTime = "Trio Up-Time"
  29. }
  30. /// Defines the available types of meal charts
  31. enum MealChartType: String, CaseIterable {
  32. /// Shows total meal statistics
  33. case totalMeals = "Total Meals"
  34. /// Shows correlation between meals and glucose excursions
  35. case mealToHypoHyperDistribution = "Meal to Hypo/Hyper"
  36. }
  37. @Observable final class StateModel: BaseStateModel<Provider> {
  38. @ObservationIgnored @Injected() var settings: SettingsManager!
  39. var highLimit: Decimal = 180
  40. var lowLimit: Decimal = 70
  41. var hbA1cDisplayUnit: HbA1cDisplayUnit = .percent
  42. var timeInRangeChartStyle: TimeInRangeChartStyle = .vertical
  43. var units: GlucoseUnits = .mgdL
  44. var glucoseFromPersistence: [GlucoseStored] = []
  45. var loopStatRecords: [LoopStatRecord] = []
  46. var groupedLoopStats: [LoopStatsByPeriod] = []
  47. var tddStats: [TDD] = []
  48. var bolusStats: [BolusStats] = []
  49. var hourlyStats: [HourlyStats] = []
  50. var glucoseRangeStats: [GlucoseRangeStats] = []
  51. var glucoseObjectIDs: [NSManagedObjectID] = [] // Cache for NSManagedObjectIDs
  52. var glucoseScrollPosition = Date() // Scroll position for glucose chart used in updateDisplayedStats()
  53. // Cache for precalculated stats
  54. private var dailyStatsCache: [Date: [HourlyStats]] = [:]
  55. private var weeklyStatsCache: [Date: [HourlyStats]] = [:] // Key: Begin of week
  56. private var monthlyStatsCache: [Date: [HourlyStats]] = [:] // Key: Begin of month
  57. private var totalStatsCache: [HourlyStats] = []
  58. // Cache for GlucoseRangeStats
  59. private var dailyRangeStatsCache: [Date: [GlucoseRangeStats]] = [:]
  60. private var weeklyRangeStatsCache: [Date: [GlucoseRangeStats]] = [:]
  61. private var monthlyRangeStatsCache: [Date: [GlucoseRangeStats]] = [:]
  62. private var totalRangeStatsCache: [GlucoseRangeStats] = []
  63. // Cache for Meal Stats
  64. var hourlyMealStats: [MealStats] = []
  65. var dailyMealStats: [MealStats] = []
  66. var dailyAveragesCache: [Date: (carbs: Double, fat: Double, protein: Double)] = [:]
  67. // Cache for TDD Stats
  68. var hourlyTDDStats: [TDDStats] = []
  69. var dailyTDDStats: [TDDStats] = []
  70. var tddAveragesCache: [Date: Double] = [:]
  71. // Cache for Bolus Stats
  72. var hourlyBolusStats: [BolusStats] = []
  73. var dailyBolusStats: [BolusStats] = []
  74. var bolusAveragesCache: [Date: (manual: Double, smb: Double, external: Double)] = [:]
  75. // Selected Duration for Glucose Stats
  76. var selectedDurationForGlucoseStats: StatsTimeInterval = .Day {
  77. didSet {
  78. Task {
  79. await precalculateStats(from: glucoseObjectIDs)
  80. await updateDisplayedStats(for: selectedGlucoseChartType)
  81. }
  82. }
  83. }
  84. // Selected Duration for Insulin Stats
  85. var selectedDurationForInsulinStats: StatsTimeInterval = .Day
  86. // Selected Duration for Meal Stats
  87. var selectedDurationForMealStats: StatsTimeInterval = .Day
  88. // Selected Duration for Loop Stats
  89. var selectedDurationForLoopStats: Duration = .Today {
  90. didSet {
  91. setupLoopStatRecords()
  92. }
  93. }
  94. // Selected Glucose Chart Type
  95. var selectedGlucoseChartType: GlucoseChartType = .percentile {
  96. didSet {
  97. Task {
  98. await updateDisplayedStats(for: selectedGlucoseChartType)
  99. }
  100. }
  101. }
  102. // Selected Insulin Chart Type
  103. var selectedInsulinChartType: InsulinChartType = .totalDailyDose
  104. // Selected Looping Chart Type
  105. var selectedLoopingChartType: LoopingChartType = .loopingPerformance
  106. // Selected Meal Chart Type
  107. var selectedMealChartType: MealChartType = .totalMeals
  108. // Fetching Contexts
  109. let context = CoreDataStack.shared.newTaskContext()
  110. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  111. let tddTaskContext = CoreDataStack.shared.newTaskContext()
  112. let loopTaskContext = CoreDataStack.shared.newTaskContext()
  113. let mealTaskContext = CoreDataStack.shared.newTaskContext()
  114. let bolusTaskContext = CoreDataStack.shared.newTaskContext()
  115. /// Defines the available time periods for duration-based statistics
  116. enum Duration: String, CaseIterable, Identifiable {
  117. /// Current day
  118. case Today
  119. /// Single day view
  120. case Day = "D"
  121. /// Week view
  122. case Week = "W"
  123. /// Month view
  124. case Month = "M"
  125. /// Three month view
  126. case Total = "3 M"
  127. var id: Self { self }
  128. }
  129. /// Defines the available time intervals for statistical analysis
  130. enum StatsTimeInterval: String, CaseIterable, Identifiable {
  131. /// Single day interval
  132. case Day = "D"
  133. /// Week interval
  134. case Week = "W"
  135. /// Month interval
  136. case Month = "M"
  137. /// Three month interval
  138. case Total = "3 M"
  139. var id: Self { self }
  140. }
  141. /// Defines the main categories of statistics available in the app
  142. enum StatisticViewType: String, CaseIterable, Identifiable {
  143. /// Glucose-related statistics including AGP and distributions
  144. case glucose
  145. /// Insulin delivery statistics including TDD and bolus distributions
  146. case insulin
  147. /// Loop performance and system status statistics
  148. case looping
  149. /// Meal-related statistics and correlations
  150. case meals
  151. var id: String { rawValue }
  152. var title: String {
  153. switch self {
  154. case .glucose: return "Glucose"
  155. case .insulin: return "Insulin"
  156. case .looping: return "Looping"
  157. case .meals: return "Meals"
  158. }
  159. }
  160. }
  161. override func subscribe() {
  162. setupGlucoseArray()
  163. setupTDDStats()
  164. setupBolusStats()
  165. setupLoopStatRecords()
  166. setupMealStats()
  167. highLimit = settingsManager.settings.high
  168. lowLimit = settingsManager.settings.low
  169. units = settingsManager.settings.units
  170. hbA1cDisplayUnit = settingsManager.settings.hbA1cDisplayUnit
  171. timeInRangeChartStyle = settingsManager.settings.timeInRangeChartStyle
  172. }
  173. /// Initializes the glucose array and calculates initial statistics
  174. func setupGlucoseArray() {
  175. Task {
  176. let ids = await fetchGlucose()
  177. await updateGlucoseArray(with: ids)
  178. await precalculateStats(from: ids)
  179. await updateDisplayedStats(for: selectedGlucoseChartType)
  180. }
  181. }
  182. /// Fetches glucose readings from CoreData for statistical analysis
  183. /// - Returns: Array of NSManagedObjectIDs for glucose readings
  184. /// Fetches only the required properties (glucose and objectID) to optimize performance
  185. private func fetchGlucose() async -> [NSManagedObjectID] {
  186. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  187. ofType: GlucoseStored.self,
  188. onContext: context,
  189. predicate: NSPredicate.glucoseForStatsTotal,
  190. key: "date",
  191. ascending: false,
  192. batchSize: 100,
  193. propertiesToFetch: ["glucose", "objectID"]
  194. )
  195. return await context.perform {
  196. guard let fetchedResults = results as? [[String: Any]] else { return [] }
  197. return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
  198. }
  199. }
  200. /// Updates the glucose array on the main actor with fetched glucose readings
  201. /// - Parameter IDs: Array of NSManagedObjectIDs to update from
  202. /// Also caches the IDs for later use in statistics calculations
  203. @MainActor private func updateGlucoseArray(with IDs: [NSManagedObjectID]) {
  204. do {
  205. let glucoseObjects = try IDs.compactMap { id in
  206. try viewContext.existingObject(with: id) as? GlucoseStored
  207. }
  208. glucoseObjectIDs = IDs // Cache IDs for later use
  209. glucoseFromPersistence = glucoseObjects
  210. } catch {
  211. debugPrint(
  212. "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error.localizedDescription)"
  213. )
  214. }
  215. }
  216. /// Precalculates statistics for both chart types (percentile and distribution) based on the selected time interval
  217. /// - Parameter ids: Array of NSManagedObjectIDs for glucose readings
  218. /// This function groups glucose values by the selected time interval (day/week/month/total)
  219. /// and calculates both hourly statistics and range distributions for each group
  220. private func precalculateStats(from ids: [NSManagedObjectID]) async {
  221. await context.perform { [self] in
  222. let glucoseValues = fetchGlucoseValues(from: ids)
  223. // Group glucose values based on selected time interval
  224. let groupedValues = groupGlucoseValues(glucoseValues, for: selectedDurationForGlucoseStats)
  225. // Calculate and cache statistics based on time interval
  226. switch selectedDurationForGlucoseStats {
  227. case .Day:
  228. dailyStatsCache = calculateStats(for: groupedValues)
  229. dailyRangeStatsCache = calculateRangeStats(for: groupedValues)
  230. case .Week:
  231. weeklyStatsCache = calculateStats(for: groupedValues)
  232. weeklyRangeStatsCache = calculateRangeStats(for: groupedValues)
  233. case .Month:
  234. monthlyStatsCache = calculateStats(for: groupedValues)
  235. monthlyRangeStatsCache = calculateRangeStats(for: groupedValues)
  236. case .Total:
  237. totalStatsCache = calculateHourlyStats(from: ids)
  238. totalRangeStatsCache = calculateGlucoseRangeStats(from: ids)
  239. }
  240. }
  241. }
  242. /// Groups glucose values based on the selected time interval
  243. /// - Parameters:
  244. /// - values: Array of glucose readings
  245. /// - interval: Selected time interval (day/week/month)
  246. /// - Returns: Dictionary with date as key and array of glucose readings as value
  247. private func groupGlucoseValues(
  248. _ values: [GlucoseStored],
  249. for interval: StatsTimeInterval
  250. ) -> [Date: [GlucoseStored]] {
  251. let calendar = Calendar.current
  252. switch interval {
  253. case .Day:
  254. return Dictionary(grouping: values) {
  255. calendar.startOfDay(for: $0.date ?? Date())
  256. }
  257. case .Week:
  258. return Dictionary(grouping: values) {
  259. calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: $0.date ?? Date()))!
  260. }
  261. case .Month:
  262. return Dictionary(grouping: values) {
  263. calendar.date(from: calendar.dateComponents([.year, .month], from: $0.date ?? Date()))!
  264. }
  265. case .Total:
  266. return [:] // Not used for total stats
  267. }
  268. }
  269. /// Helper function to safely fetch glucose values from CoreData
  270. /// - Parameter ids: Array of NSManagedObjectIDs
  271. /// - Returns: Array of GlucoseStored objects
  272. func fetchGlucoseValues(from ids: [NSManagedObjectID]) -> [GlucoseStored] {
  273. ids.compactMap { id -> GlucoseStored? in
  274. do {
  275. return try context.existingObject(with: id) as? GlucoseStored
  276. } catch let error as NSError {
  277. debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error.userInfo)")
  278. return nil
  279. }
  280. }
  281. }
  282. /// Updates the displayed statistics based on the selected chart type and time interval
  283. /// - Parameter chartType: The type of chart being displayed (percentile or distribution)
  284. @MainActor func updateDisplayedStats(for chartType: GlucoseChartType) {
  285. let calendar = Calendar.current
  286. // Get the appropriate start date based on the selected time interval
  287. let startDate: Date = {
  288. switch selectedDurationForGlucoseStats {
  289. case .Day:
  290. return calendar.startOfDay(for: glucoseScrollPosition)
  291. case .Week:
  292. return calendar
  293. .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: glucoseScrollPosition))!
  294. case .Month:
  295. return calendar.date(from: calendar.dateComponents([.year, .month], from: glucoseScrollPosition))!
  296. case .Total:
  297. return glucoseScrollPosition
  298. }
  299. }()
  300. // Update the appropriate stats based on chart type
  301. switch (selectedDurationForGlucoseStats, chartType) {
  302. case (.Day, .percentile):
  303. hourlyStats = dailyStatsCache[startDate] ?? []
  304. case (.Day, .distribution):
  305. glucoseRangeStats = dailyRangeStatsCache[startDate] ?? []
  306. case (.Week, .percentile):
  307. hourlyStats = weeklyStatsCache[startDate] ?? []
  308. case (.Week, .distribution):
  309. glucoseRangeStats = weeklyRangeStatsCache[startDate] ?? []
  310. case (.Month, .percentile):
  311. hourlyStats = monthlyStatsCache[startDate] ?? []
  312. case (.Month, .distribution):
  313. glucoseRangeStats = monthlyRangeStatsCache[startDate] ?? []
  314. case (.Total, .percentile):
  315. hourlyStats = totalStatsCache
  316. case (.Total, .distribution):
  317. glucoseRangeStats = totalRangeStatsCache
  318. }
  319. }
  320. }
  321. @Observable final class UpdateTimer {
  322. private var workItem: DispatchWorkItem?
  323. /// Schedules a delayed update action
  324. /// - Parameter action: The closure to execute after the delay
  325. /// Cancels any previously scheduled update before scheduling a new one
  326. func scheduleUpdate(action: @escaping () -> Void) {
  327. workItem?.cancel()
  328. let newWorkItem = DispatchWorkItem {
  329. action()
  330. }
  331. workItem = newWorkItem
  332. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: newWorkItem)
  333. }
  334. }
  335. }