StatStateModel.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  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. // Cache for Glucose Daily Stats
  36. @MainActor var dailyGlucosePercentileStats: [GlucoseDailyPercentileStats] = []
  37. @MainActor var glucosePercentileCache: [Date: GlucoseDailyPercentileStats] = [:]
  38. @MainActor var dailyGlucoseDistributionStats: [GlucoseDailyDistributionStats] = []
  39. @MainActor var glucoseDistributionCache: [Date: GlucoseDailyDistributionStats] = [:]
  40. var glucoseReadings: [GlucoseStored] = []
  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 = .percentileByTime
  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. setupGlucoseDailyStats()
  79. units = settingsManager.settings.units
  80. eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
  81. useFPUconversion = settingsManager.settings.useFPUconversion
  82. timeInRangeType = settingsManager.settings.timeInRangeType
  83. }
  84. func setupGlucoseArray(for interval: StatsTimeIntervalWithToday) {
  85. Task {
  86. // Load data for current interval (existing code)
  87. let ids = await fetchGlucose(for: interval)
  88. await updateGlucoseArray(with: ids)
  89. // Also ensure we have the full dataset loaded
  90. if glucoseReadings.isEmpty {
  91. let allIds = await fetchGlucose(for: .total)
  92. await updateAllGlucoseArray(with: allIds)
  93. }
  94. // Calculate hourly stats and glucose range stats asynchronously with fetched glucose IDs
  95. async let hourlyStats: () = calculateHourlyStatsForGlucoseAreaChart(from: ids)
  96. async let glucoseRangeStats: () = calculateGlucoseRangeStatsForStackedChart(from: ids)
  97. _ = await (hourlyStats, glucoseRangeStats)
  98. }
  99. }
  100. func setupGlucoseDailyStats() {
  101. Task {
  102. // Get glucose IDs once (using the private fetchGlucose method)
  103. let allIds = await fetchGlucose(for: .total)
  104. // Pass the IDs to the implementation in GlucoseStatsSetup.swift
  105. await setupGlucoseStats(with: allIds)
  106. }
  107. }
  108. private func fetchGlucose(for interval: StatsTimeIntervalWithToday) async -> [NSManagedObjectID] {
  109. do {
  110. let predicate: NSPredicate
  111. switch interval {
  112. case .day:
  113. predicate = NSPredicate.glucoseForStatsDay
  114. case .week:
  115. predicate = NSPredicate.glucoseForStatsWeek
  116. case .today:
  117. predicate = NSPredicate.glucoseForStatsToday
  118. case .month:
  119. predicate = NSPredicate.glucoseForStatsMonth
  120. case .total:
  121. predicate = NSPredicate.glucoseForStatsTotal
  122. }
  123. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  124. ofType: GlucoseStored.self,
  125. onContext: context,
  126. predicate: predicate,
  127. key: "date",
  128. ascending: false,
  129. batchSize: 100,
  130. propertiesToFetch: ["glucose", "objectID"]
  131. )
  132. return try await context.perform {
  133. guard let fetchedResults = results as? [[String: Any]] else {
  134. throw CoreDataError.fetchError(function: #function, file: #file)
  135. }
  136. return fetchedResults.compactMap { $0["objectID"] as? NSManagedObjectID }
  137. }
  138. } catch {
  139. debug(.default, "\(DebuggingIdentifiers.failed) Error fetching glucose for stats: \(error)")
  140. return []
  141. }
  142. }
  143. @MainActor private func updateGlucoseArray(with IDs: [NSManagedObjectID]) {
  144. do {
  145. let glucoseObjects = try IDs.compactMap { id in
  146. try viewContext.existingObject(with: id) as? GlucoseStored
  147. }
  148. glucoseFromPersistence = glucoseObjects
  149. } catch {
  150. debugPrint(
  151. "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the glucose array: \(error)"
  152. )
  153. }
  154. }
  155. @MainActor private func updateAllGlucoseArray(with IDs: [NSManagedObjectID]) {
  156. do {
  157. let glucoseObjects = try IDs.compactMap { id in
  158. try viewContext.existingObject(with: id) as? GlucoseStored
  159. }
  160. glucoseReadings = glucoseObjects
  161. } catch {
  162. debugPrint(
  163. "Home State: \(#function) \(DebuggingIdentifiers.failed) error while updating the all glucose array: \(error.localizedDescription)"
  164. )
  165. }
  166. }
  167. }
  168. @Observable final class UpdateTimer {
  169. private var workItem: DispatchWorkItem?
  170. /// Schedules a delayed update action
  171. /// - Parameter action: The closure to execute after the delay
  172. /// Cancels any previously scheduled update before scheduling a new one
  173. func scheduleUpdate(action: @escaping () -> Void) {
  174. workItem?.cancel()
  175. let newWorkItem = DispatchWorkItem {
  176. action()
  177. }
  178. workItem = newWorkItem
  179. DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: newWorkItem)
  180. }
  181. }
  182. }
  183. // MARK: Stats Types + Enums
  184. extension Stat.StateModel {
  185. /// Defines the available types of glucose charts
  186. enum GlucoseChartType: String, CaseIterable {
  187. /// Ambulatory Glucose Profile showing percentile ranges
  188. case percentileByTime = "Percentile (by Time)"
  189. /// Time-based distribution of glucose ranges
  190. case distributionByTime = "Distribution (by Time)"
  191. /// Day-based box plot of glucose percentile ranges
  192. case percentileByDay = "Percentile (by Day)"
  193. /// Day-based distribution of glucose ranges
  194. case distributionByDay = "Distribution (by Day)"
  195. var displayName: String {
  196. switch self {
  197. case .percentileByTime:
  198. return String(localized: "Percentile (by Time)")
  199. case .distributionByTime:
  200. return String(localized: "Distribution (by Time)")
  201. case .percentileByDay:
  202. return String(localized: "Percentile (by Day)")
  203. case .distributionByDay:
  204. return String(localized: "Distribution (by Day)")
  205. }
  206. }
  207. }
  208. /// Defines the available types of insulin charts
  209. enum InsulinChartType: String, CaseIterable {
  210. /// Shows total daily insulin doses
  211. case totalDailyDose = "Total Daily Dose"
  212. /// Shows distribution of bolus types
  213. case bolusDistribution = "Bolus Distribution"
  214. var displayName: String {
  215. switch self {
  216. case .totalDailyDose:
  217. return String(localized: "Total Daily Dose")
  218. case .bolusDistribution:
  219. return String(localized: "Bolus Distribution")
  220. }
  221. }
  222. }
  223. /// Defines the available types of looping charts
  224. enum LoopingChartType: String, CaseIterable {
  225. /// Shows loop completion and success rates
  226. case loopingPerformance = "Looping Performance"
  227. /// Shows CGM connection status over time
  228. case cgmConnectionTrace = "CGM Connection Trace"
  229. /// Shows Trio pump uptime statistics
  230. case trioUpTime = "Trio Up-Time"
  231. var displayName: String {
  232. switch self {
  233. case .loopingPerformance:
  234. return String(localized: "Looping Performance")
  235. case .cgmConnectionTrace:
  236. return String(localized: "CGM Connection Trace")
  237. case .trioUpTime:
  238. return String(localized: "Trio Up-Time")
  239. }
  240. }
  241. }
  242. /// Defines the available types of meal charts
  243. enum MealChartType: String, CaseIterable {
  244. /// Shows total meal statistics
  245. case totalMeals = "Total Meals"
  246. /// Shows correlation between meals and glucose excursions
  247. case mealToHypoHyperDistribution = "Meal to Hypo/Hyper"
  248. var displayName: String {
  249. switch self {
  250. case .totalMeals:
  251. return String(localized: "Total Meals")
  252. case .mealToHypoHyperDistribution:
  253. return String(localized: "Meal to Hypo/Hyper")
  254. }
  255. }
  256. }
  257. /// Defines the available time periods for duration-based statistics including 'Today' (time since midnight until now)
  258. enum StatsTimeIntervalWithToday: String, CaseIterable, Identifiable {
  259. /// Current day
  260. case today
  261. /// Single day view
  262. case day = "D"
  263. /// Week view
  264. case week = "W"
  265. /// Month view
  266. case month = "M"
  267. /// Three month view
  268. case total = "3 M"
  269. var id: Self { self }
  270. var displayName: String {
  271. switch self {
  272. case .today:
  273. return String(localized: "Today")
  274. case .day:
  275. return String(localized: "D", comment: "Abbreviation for day")
  276. case .week:
  277. return String(localized: "W", comment: "Abbreviation for week")
  278. case .month:
  279. return String(localized: "M", comment: "Abbreviation for month")
  280. case .total:
  281. return String(localized: "3 M", comment: "Abbreviation for three months")
  282. }
  283. }
  284. }
  285. /// Defines the available time periods for duration-based statistics
  286. enum StatsTimeInterval: String, CaseIterable, Identifiable {
  287. /// Single day interval
  288. case day = "D"
  289. /// Week interval
  290. case week = "W"
  291. /// Month interval
  292. case month = "M"
  293. /// Three month interval
  294. case total = "3 M"
  295. var id: Self { self }
  296. var displayName: String {
  297. switch self {
  298. case .day:
  299. return String(localized: "D", comment: "Abbreviation for day")
  300. case .week:
  301. return String(localized: "W", comment: "Abbreviation for week")
  302. case .month:
  303. return String(localized: "M", comment: "Abbreviation for month")
  304. case .total:
  305. return String(localized: "3 M", comment: "Abbreviation for three months")
  306. }
  307. }
  308. }
  309. /// Defines the main categories of statistics available in the app
  310. enum StatisticViewType: String, CaseIterable, Identifiable {
  311. /// Glucose-related statistics including AGP and distributions
  312. case glucose
  313. /// Insulin delivery statistics including TDD and bolus distributions
  314. case insulin
  315. /// Loop performance and system status statistics
  316. case looping
  317. /// Meal-related statistics and correlations
  318. case meals
  319. var id: String { rawValue }
  320. var displayName: String {
  321. switch self {
  322. case .glucose:
  323. return String(localized: "Glucose", comment: "Title for glucose-related statistics")
  324. case .insulin:
  325. return String(localized: "Insulin", comment: "Title for insulin-related statistics")
  326. case .looping:
  327. return String(localized: "Looping", comment: "Title for looping and system statistics")
  328. case .meals:
  329. return String(localized: "Meals", comment: "Title for meal-related statistics")
  330. }
  331. }
  332. }
  333. }