StatStateModel.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  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 Bolus Stats
  68. var hourlyBolusStats: [BolusStats] = []
  69. var dailyBolusStats: [BolusStats] = []
  70. var bolusAveragesCache: [Date: (manual: Double, smb: Double, external: Double)] = [:]
  71. // Selected Duration for Glucose Stats
  72. var selectedDurationForGlucoseStats: StatsTimeInterval = .Day {
  73. didSet {
  74. Task {
  75. await precalculateStats(from: glucoseObjectIDs)
  76. await updateDisplayedStats(for: selectedGlucoseChartType)
  77. }
  78. }
  79. }
  80. // Selected Duration for Insulin Stats
  81. var selectedDurationForInsulinStats: StatsTimeInterval = .Day
  82. // Selected Duration for Meal Stats
  83. var selectedDurationForMealStats: StatsTimeInterval = .Day
  84. // Selected Duration for Loop Stats
  85. var selectedDurationForLoopStats: Duration = .Today {
  86. didSet {
  87. setupLoopStatRecords()
  88. }
  89. }
  90. // Selected Glucose Chart Type
  91. var selectedGlucoseChartType: GlucoseChartType = .percentile {
  92. didSet {
  93. Task {
  94. await updateDisplayedStats(for: selectedGlucoseChartType)
  95. }
  96. }
  97. }
  98. // Selected Insulin Chart Type
  99. var selectedInsulinChartType: InsulinChartType = .totalDailyDose
  100. // Selected Looping Chart Type
  101. var selectedLoopingChartType: LoopingChartType = .loopingPerformance
  102. // Selected Meal Chart Type
  103. var selectedMealChartType: MealChartType = .totalMeals
  104. // Fetching Contexts
  105. let context = CoreDataStack.shared.newTaskContext()
  106. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  107. let determinationFetchContext = CoreDataStack.shared.newTaskContext()
  108. let loopTaskContext = CoreDataStack.shared.newTaskContext()
  109. let mealTaskContext = CoreDataStack.shared.newTaskContext()
  110. let bolusTaskContext = CoreDataStack.shared.newTaskContext()
  111. /// Defines the available time periods for duration-based statistics
  112. enum Duration: String, CaseIterable, Identifiable {
  113. /// Current day
  114. case Today
  115. /// Single day view
  116. case Day = "D"
  117. /// Week view
  118. case Week = "W"
  119. /// Month view
  120. case Month = "M"
  121. /// Three month view
  122. case Total = "3 M"
  123. var id: Self { self }
  124. }
  125. /// Defines the available time intervals for statistical analysis
  126. enum StatsTimeInterval: String, CaseIterable, Identifiable {
  127. /// Single day interval
  128. case Day = "D"
  129. /// Week interval
  130. case Week = "W"
  131. /// Month interval
  132. case Month = "M"
  133. /// Three month interval
  134. case Total = "3 M"
  135. var id: Self { self }
  136. }
  137. /// Defines the main categories of statistics available in the app
  138. enum StatisticViewType: String, CaseIterable, Identifiable {
  139. /// Glucose-related statistics including AGP and distributions
  140. case glucose
  141. /// Insulin delivery statistics including TDD and bolus distributions
  142. case insulin
  143. /// Loop performance and system status statistics
  144. case looping
  145. /// Meal-related statistics and correlations
  146. case meals
  147. var id: String { rawValue }
  148. var title: String {
  149. switch self {
  150. case .glucose: return "Glucose"
  151. case .insulin: return "Insulin"
  152. case .looping: return "Looping"
  153. case .meals: return "Meals"
  154. }
  155. }
  156. }
  157. override func subscribe() {
  158. setupGlucoseArray()
  159. setupTDDs()
  160. setupBolusStats()
  161. setupLoopStatRecords()
  162. setupMealStats()
  163. highLimit = settingsManager.settings.high
  164. lowLimit = settingsManager.settings.low
  165. units = settingsManager.settings.units
  166. hbA1cDisplayUnit = settingsManager.settings.hbA1cDisplayUnit
  167. timeInRangeChartStyle = settingsManager.settings.timeInRangeChartStyle
  168. }
  169. /// Initializes the glucose array and calculates initial statistics
  170. func setupGlucoseArray() {
  171. Task {
  172. let ids = await fetchGlucose()
  173. await updateGlucoseArray(with: ids)
  174. await precalculateStats(from: ids)
  175. await updateDisplayedStats(for: selectedGlucoseChartType)
  176. }
  177. }
  178. /// Fetches glucose readings from CoreData for statistical analysis
  179. /// - Returns: Array of NSManagedObjectIDs for glucose readings
  180. /// Fetches only the required properties (glucose and objectID) to optimize performance
  181. private func fetchGlucose() async -> [NSManagedObjectID] {
  182. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  183. ofType: GlucoseStored.self,
  184. onContext: context,
  185. predicate: NSPredicate.glucoseForStatsTotal,
  186. key: "date",
  187. ascending: false,
  188. batchSize: 100,
  189. propertiesToFetch: ["glucose", "objectID"]
  190. )
  191. return await context.perform {
  192. guard let fetchedResults = results as? [[String: Any]] else { return [] }
  193. return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
  194. }
  195. }
  196. /// Updates the glucose array on the main actor with fetched glucose readings
  197. /// - Parameter IDs: Array of NSManagedObjectIDs to update from
  198. /// Also caches the IDs for later use in statistics calculations
  199. @MainActor private func updateGlucoseArray(with IDs: [NSManagedObjectID]) {
  200. do {
  201. let glucoseObjects = try IDs.compactMap { id in
  202. try viewContext.existingObject(with: id) as? GlucoseStored
  203. }
  204. glucoseObjectIDs = IDs // Cache IDs for later use
  205. glucoseFromPersistence = glucoseObjects
  206. } catch {
  207. debugPrint(
  208. "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error.localizedDescription)"
  209. )
  210. }
  211. }
  212. /// Precalculates statistics for both chart types (percentile and distribution) based on the selected time interval
  213. /// - Parameter ids: Array of NSManagedObjectIDs for glucose readings
  214. /// This function groups glucose values by the selected time interval (day/week/month/total)
  215. /// and calculates both hourly statistics and range distributions for each group
  216. private func precalculateStats(from ids: [NSManagedObjectID]) async {
  217. await context.perform { [self] in
  218. let glucoseValues = fetchGlucoseValues(from: ids)
  219. // Group glucose values based on selected time interval
  220. let groupedValues = groupGlucoseValues(glucoseValues, for: selectedDurationForGlucoseStats)
  221. // Calculate and cache statistics based on time interval
  222. switch selectedDurationForGlucoseStats {
  223. case .Day:
  224. dailyStatsCache = calculateStats(for: groupedValues)
  225. dailyRangeStatsCache = calculateRangeStats(for: groupedValues)
  226. case .Week:
  227. weeklyStatsCache = calculateStats(for: groupedValues)
  228. weeklyRangeStatsCache = calculateRangeStats(for: groupedValues)
  229. case .Month:
  230. monthlyStatsCache = calculateStats(for: groupedValues)
  231. monthlyRangeStatsCache = calculateRangeStats(for: groupedValues)
  232. case .Total:
  233. totalStatsCache = calculateHourlyStats(from: ids)
  234. totalRangeStatsCache = calculateGlucoseRangeStats(from: ids)
  235. }
  236. }
  237. }
  238. /// Groups glucose values based on the selected time interval
  239. /// - Parameters:
  240. /// - values: Array of glucose readings
  241. /// - interval: Selected time interval (day/week/month)
  242. /// - Returns: Dictionary with date as key and array of glucose readings as value
  243. private func groupGlucoseValues(
  244. _ values: [GlucoseStored],
  245. for interval: StatsTimeInterval
  246. ) -> [Date: [GlucoseStored]] {
  247. let calendar = Calendar.current
  248. switch interval {
  249. case .Day:
  250. return Dictionary(grouping: values) {
  251. calendar.startOfDay(for: $0.date ?? Date())
  252. }
  253. case .Week:
  254. return Dictionary(grouping: values) {
  255. calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: $0.date ?? Date()))!
  256. }
  257. case .Month:
  258. return Dictionary(grouping: values) {
  259. calendar.date(from: calendar.dateComponents([.year, .month], from: $0.date ?? Date()))!
  260. }
  261. case .Total:
  262. return [:] // Not used for total stats
  263. }
  264. }
  265. /// Helper function to safely fetch glucose values from CoreData
  266. /// - Parameter ids: Array of NSManagedObjectIDs
  267. /// - Returns: Array of GlucoseStored objects
  268. func fetchGlucoseValues(from ids: [NSManagedObjectID]) -> [GlucoseStored] {
  269. ids.compactMap { id -> GlucoseStored? in
  270. do {
  271. return try context.existingObject(with: id) as? GlucoseStored
  272. } catch let error as NSError {
  273. debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error.userInfo)")
  274. return nil
  275. }
  276. }
  277. }
  278. /// Updates the displayed statistics based on the selected chart type and time interval
  279. /// - Parameter chartType: The type of chart being displayed (percentile or distribution)
  280. @MainActor func updateDisplayedStats(for chartType: GlucoseChartType) {
  281. let calendar = Calendar.current
  282. // Get the appropriate start date based on the selected time interval
  283. let startDate: Date = {
  284. switch selectedDurationForGlucoseStats {
  285. case .Day:
  286. return calendar.startOfDay(for: glucoseScrollPosition)
  287. case .Week:
  288. return calendar
  289. .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: glucoseScrollPosition))!
  290. case .Month:
  291. return calendar.date(from: calendar.dateComponents([.year, .month], from: glucoseScrollPosition))!
  292. case .Total:
  293. return glucoseScrollPosition
  294. }
  295. }()
  296. // Update the appropriate stats based on chart type
  297. switch (selectedDurationForGlucoseStats, chartType) {
  298. case (.Day, .percentile):
  299. hourlyStats = dailyStatsCache[startDate] ?? []
  300. case (.Day, .distribution):
  301. glucoseRangeStats = dailyRangeStatsCache[startDate] ?? []
  302. case (.Week, .percentile):
  303. hourlyStats = weeklyStatsCache[startDate] ?? []
  304. case (.Week, .distribution):
  305. glucoseRangeStats = weeklyRangeStatsCache[startDate] ?? []
  306. case (.Month, .percentile):
  307. hourlyStats = monthlyStatsCache[startDate] ?? []
  308. case (.Month, .distribution):
  309. glucoseRangeStats = monthlyRangeStatsCache[startDate] ?? []
  310. case (.Total, .percentile):
  311. hourlyStats = totalStatsCache
  312. case (.Total, .distribution):
  313. glucoseRangeStats = totalRangeStatsCache
  314. }
  315. }
  316. }
  317. @Observable final class UpdateTimer {
  318. private var workItem: DispatchWorkItem?
  319. /// Schedules a delayed update action
  320. /// - Parameter action: The closure to execute after the delay
  321. /// Cancels any previously scheduled update before scheduling a new one
  322. func scheduleUpdate(action: @escaping () -> Void) {
  323. workItem?.cancel()
  324. let newWorkItem = DispatchWorkItem {
  325. action()
  326. }
  327. workItem = newWorkItem
  328. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: newWorkItem)
  329. }
  330. }
  331. }