StatStateModel.swift 15 KB

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