StatStateModel.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import CoreData
  2. import Foundation
  3. import Observation
  4. import SwiftUI
  5. import Swinject
  6. extension Stat {
  7. @Observable final class StateModel: BaseStateModel<Provider> {
  8. @ObservationIgnored @Injected() var settings: SettingsManager!
  9. var highLimit: Decimal = 180
  10. var lowLimit: Decimal = 70
  11. var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
  12. var units: GlucoseUnits = .mgdL
  13. var timeInRangeType: TimeInRangeType = .timeInTightRange
  14. var useFPUconversion: Bool = false
  15. var glucoseFromPersistence: [GlucoseStored] = []
  16. var loopStatRecords: [LoopStatRecord] = []
  17. var loopStats: [LoopStatsProcessedData] = []
  18. var groupedLoopStats: [LoopStatsByPeriod] = []
  19. var bolusStats: [BolusStats] = []
  20. var hourlyStats: [HourlyStats] = []
  21. var glucoseRangeStats: [GlucoseRangeStats] = []
  22. // Cache for Meal Stats
  23. var hourlyMealStats: [MealStats] = []
  24. var dailyMealStats: [MealStats] = []
  25. var dailyAveragesCache: [Date: (carbs: Double, fat: Double, protein: Double)] = [:]
  26. // Cache for TDD Stats
  27. var hourlyTDDStats: [TDDStats] = []
  28. var dailyTDDStats: [TDDStats] = []
  29. var tddAveragesCache: [Date: Double] = [:]
  30. // Cache for Bolus Stats
  31. var hourlyBolusStats: [BolusStats] = []
  32. var dailyBolusStats: [BolusStats] = []
  33. var bolusAveragesCache: [Date: (manual: Double, smb: Double, external: Double)] = [:]
  34. var bolusTotalsCache: [(Date, total: Double)] = []
  35. // Selected Duration for Glucose Stats
  36. var selectedIntervalForGlucoseStats: StatsTimeIntervalWithToday = .today {
  37. didSet {
  38. setupGlucoseArray(for: selectedIntervalForGlucoseStats)
  39. }
  40. }
  41. // Selected Duration for Insulin Stats
  42. var selectedIntervalForInsulinStats: StatsTimeInterval = .day
  43. // Selected Duration for Meal Stats
  44. var selectedIntervalForMealStats: StatsTimeInterval = .day
  45. // Selected Duration for Loop Stats
  46. var selectedIntervalForLoopStats: StatsTimeIntervalWithToday = .today {
  47. didSet {
  48. setupLoopStatRecords()
  49. }
  50. }
  51. // Selected Glucose Chart Type
  52. var selectedGlucoseChartType: GlucoseChartType = .percentile
  53. // Selected Insulin Chart Type
  54. var selectedInsulinChartType: InsulinChartType = .totalDailyDose
  55. // Selected Looping Chart Type
  56. var selectedLoopingChartType: LoopingChartType = .loopingPerformance
  57. // Selected Meal Chart Type
  58. var selectedMealChartType: MealChartType = .totalMeals
  59. // Fetching Contexts
  60. let context = CoreDataStack.shared.newTaskContext()
  61. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  62. let tddTaskContext = CoreDataStack.shared.newTaskContext()
  63. let loopTaskContext = CoreDataStack.shared.newTaskContext()
  64. let mealTaskContext = CoreDataStack.shared.newTaskContext()
  65. let bolusTaskContext = CoreDataStack.shared.newTaskContext()
  66. override func subscribe() {
  67. setupGlucoseArray(for: .today)
  68. setupTDDStats()
  69. setupBolusStats()
  70. setupLoopStatRecords()
  71. setupMealStats()
  72. units = settingsManager.settings.units
  73. eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
  74. useFPUconversion = settingsManager.settings.useFPUconversion
  75. timeInRangeType = settingsManager.settings.timeInRangeType
  76. }
  77. func setupGlucoseArray(for interval: StatsTimeIntervalWithToday) {
  78. Task {
  79. let ids = await fetchGlucose(for: interval)
  80. await updateGlucoseArray(with: ids)
  81. // Calculate hourly stats and glucose range stats asynchronously with fetched glucose IDs
  82. async let hourlyStats: () = calculateHourlyStatsForGlucoseAreaChart(from: ids)
  83. async let glucoseRangeStats: () = calculateGlucoseRangeStatsForStackedChart(from: ids)
  84. _ = await (hourlyStats, glucoseRangeStats)
  85. }
  86. }
  87. private func fetchGlucose(for interval: StatsTimeIntervalWithToday) async -> [NSManagedObjectID] {
  88. do {
  89. let predicate: NSPredicate
  90. switch interval {
  91. case .day:
  92. predicate = NSPredicate.glucoseForStatsDay
  93. case .week:
  94. predicate = NSPredicate.glucoseForStatsWeek
  95. case .today:
  96. predicate = NSPredicate.glucoseForStatsToday
  97. case .month:
  98. predicate = NSPredicate.glucoseForStatsMonth
  99. case .total:
  100. predicate = NSPredicate.glucoseForStatsTotal
  101. }
  102. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  103. ofType: GlucoseStored.self,
  104. onContext: context,
  105. predicate: predicate,
  106. key: "date",
  107. ascending: false,
  108. batchSize: 100,
  109. propertiesToFetch: ["glucose", "objectID"]
  110. )
  111. return try await context.perform {
  112. guard let fetchedResults = results as? [[String: Any]] else {
  113. throw CoreDataError.fetchError(function: #function, file: #file)
  114. }
  115. return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
  116. }
  117. } catch {
  118. debug(.default, "\(DebuggingIdentifiers.failed) Error fetching glucose for stats: \(error.localizedDescription)")
  119. return []
  120. }
  121. }
  122. @MainActor private func updateGlucoseArray(with IDs: [NSManagedObjectID]) {
  123. do {
  124. let glucoseObjects = try IDs.compactMap { id in
  125. try viewContext.existingObject(with: id) as? GlucoseStored
  126. }
  127. glucoseFromPersistence = glucoseObjects
  128. } catch {
  129. debugPrint(
  130. "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error.localizedDescription)"
  131. )
  132. }
  133. }
  134. }
  135. @Observable final class UpdateTimer {
  136. private var workItem: DispatchWorkItem?
  137. /// Schedules a delayed update action
  138. /// - Parameter action: The closure to execute after the delay
  139. /// Cancels any previously scheduled update before scheduling a new one
  140. func scheduleUpdate(action: @escaping () -> Void) {
  141. workItem?.cancel()
  142. let newWorkItem = DispatchWorkItem {
  143. action()
  144. }
  145. workItem = newWorkItem
  146. DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: newWorkItem)
  147. }
  148. }
  149. }
  150. // MARK: Stats Types + Enums
  151. extension Stat.StateModel {
  152. /// Defines the available types of glucose charts
  153. enum GlucoseChartType: String, CaseIterable {
  154. /// Ambulatory Glucose Profile showing percentile ranges
  155. case percentile = "Percentile"
  156. /// Time-based distribution of glucose ranges
  157. case distribution = "Distribution"
  158. var displayName: String {
  159. switch self {
  160. case .percentile:
  161. return String(localized: "Percentile")
  162. case .distribution:
  163. return String(localized: "Distribution")
  164. }
  165. }
  166. }
  167. /// Defines the available types of insulin charts
  168. enum InsulinChartType: String, CaseIterable {
  169. /// Shows total daily insulin doses
  170. case totalDailyDose = "Total Daily Dose"
  171. /// Shows distribution of bolus types
  172. case bolusDistribution = "Bolus Distribution"
  173. var displayName: String {
  174. switch self {
  175. case .totalDailyDose:
  176. return String(localized: "Total Daily Dose")
  177. case .bolusDistribution:
  178. return String(localized: "Bolus Distribution")
  179. }
  180. }
  181. }
  182. /// Defines the available types of looping charts
  183. enum LoopingChartType: String, CaseIterable {
  184. /// Shows loop completion and success rates
  185. case loopingPerformance = "Looping Performance"
  186. /// Shows CGM connection status over time
  187. case cgmConnectionTrace = "CGM Connection Trace"
  188. /// Shows Trio pump uptime statistics
  189. case trioUpTime = "Trio Up-Time"
  190. var displayName: String {
  191. switch self {
  192. case .loopingPerformance:
  193. return String(localized: "Looping Performance")
  194. case .cgmConnectionTrace:
  195. return String(localized: "CGM Connection Trace")
  196. case .trioUpTime:
  197. return String(localized: "Trio Up-Time")
  198. }
  199. }
  200. }
  201. /// Defines the available types of meal charts
  202. enum MealChartType: String, CaseIterable {
  203. /// Shows total meal statistics
  204. case totalMeals = "Total Meals"
  205. /// Shows correlation between meals and glucose excursions
  206. case mealToHypoHyperDistribution = "Meal to Hypo/Hyper"
  207. var displayName: String {
  208. switch self {
  209. case .totalMeals:
  210. return String(localized: "Total Meals")
  211. case .mealToHypoHyperDistribution:
  212. return String(localized: "Meal to Hypo/Hyper")
  213. }
  214. }
  215. }
  216. /// Defines the available time periods for duration-based statistics including 'Today' (time since midnight until now)
  217. enum StatsTimeIntervalWithToday: String, CaseIterable, Identifiable {
  218. /// Current day
  219. case today
  220. /// Single day view
  221. case day = "D"
  222. /// Week view
  223. case week = "W"
  224. /// Month view
  225. case month = "M"
  226. /// Three month view
  227. case total = "3 M"
  228. var id: Self { self }
  229. var displayName: String {
  230. switch self {
  231. case .today:
  232. return String(localized: "Today")
  233. case .day:
  234. return String(localized: "D", comment: "Abbreviation for day")
  235. case .week:
  236. return String(localized: "W", comment: "Abbreviation for week")
  237. case .month:
  238. return String(localized: "M", comment: "Abbreviation for month")
  239. case .total:
  240. return String(localized: "3 M", comment: "Abbreviation for three months")
  241. }
  242. }
  243. }
  244. /// Defines the available time periods for duration-based statistics
  245. enum StatsTimeInterval: String, CaseIterable, Identifiable {
  246. /// Single day interval
  247. case day = "D"
  248. /// Week interval
  249. case week = "W"
  250. /// Month interval
  251. case month = "M"
  252. /// Three month interval
  253. case total = "3 M"
  254. var id: Self { self }
  255. var displayName: String {
  256. switch self {
  257. case .day:
  258. return String(localized: "D", comment: "Abbreviation for day")
  259. case .week:
  260. return String(localized: "W", comment: "Abbreviation for week")
  261. case .month:
  262. return String(localized: "M", comment: "Abbreviation for month")
  263. case .total:
  264. return String(localized: "3 M", comment: "Abbreviation for three months")
  265. }
  266. }
  267. }
  268. /// Defines the main categories of statistics available in the app
  269. enum StatisticViewType: String, CaseIterable, Identifiable {
  270. /// Glucose-related statistics including AGP and distributions
  271. case glucose
  272. /// Insulin delivery statistics including TDD and bolus distributions
  273. case insulin
  274. /// Loop performance and system status statistics
  275. case looping
  276. /// Meal-related statistics and correlations
  277. case meals
  278. var id: String { rawValue }
  279. var displayName: String {
  280. switch self {
  281. case .glucose:
  282. return String(localized: "Glucose", comment: "Title for glucose-related statistics")
  283. case .insulin:
  284. return String(localized: "Insulin", comment: "Title for insulin-related statistics")
  285. case .looping:
  286. return String(localized: "Looping", comment: "Title for looping and system statistics")
  287. case .meals:
  288. return String(localized: "Meals", comment: "Title for meal-related statistics")
  289. }
  290. }
  291. }
  292. }