StatStateModel.swift 12 KB

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