StatStateModel.swift 15 KB

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