StackedChartSetup.swift 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import CoreData
  2. import Foundation
  3. /// Represents the distribution of glucose values within specific ranges for each hour.
  4. ///
  5. /// This struct is used to visualize how glucose values are distributed across different
  6. /// ranges (e.g., low, normal, high) throughout the day. Each range has a name and
  7. /// corresponding hourly values showing the percentage of readings in that range.
  8. ///
  9. /// Example ranges and their meanings:
  10. /// - "<54": Urgent low
  11. /// - "54-70": Low
  12. /// - "70-140": Target range
  13. /// - "140-180": High
  14. /// - "180-200": Very high
  15. /// - "200-220": Very high+
  16. /// - ">220": Urgent high
  17. ///
  18. /// Example usage:
  19. /// ```swift
  20. /// let range = GlucoseRangeStats(
  21. /// name: "70-140", // Target range
  22. /// values: [
  23. /// (hour: 8, count: 75), // 75% of readings at 8 AM were in range
  24. /// (hour: 9, count: 80) // 80% of readings at 9 AM were in range
  25. /// ]
  26. /// )
  27. /// ```
  28. ///
  29. /// This data structure is used to create stacked area charts showing the
  30. /// distribution of glucose values across different ranges for each hour of the day.
  31. public struct GlucoseRangeStats: Identifiable {
  32. /// The name of the glucose range (e.g., "70-140", "<54")
  33. let name: String
  34. /// Array of tuples containing the hour and percentage of readings in this range
  35. /// - hour: Hour of the day (0-23)
  36. /// - count: Percentage of readings in this range for the given hour (0-100)
  37. let values: [(hour: Int, count: Int)]
  38. /// Unique identifier for the range, derived from its name
  39. public var id: String { name }
  40. }
  41. extension Stat.StateModel {
  42. /// Calculates hourly glucose range distribution statistics.
  43. /// The calculation runs asynchronously using the CoreData context.
  44. ///
  45. /// The calculation works as follows:
  46. /// 1. Count unique days for each hour to handle missing data
  47. /// 2. For each glucose range and hour:
  48. /// - Count readings in that range
  49. /// - Calculate percentage based on number of days with readings
  50. ///
  51. /// Example:
  52. /// If we have data for 7 days and at 6:00 AM:
  53. /// - 3 days had readings in range 70-140
  54. /// - 2 days had readings in range 140-180
  55. /// - 2 day had a reading in range 180-200
  56. /// Then for 6:00 AM:
  57. /// - 70-140 = (3/7)*100 = 42.9%
  58. /// - 140-180 = (2/7)*100 = 28.6%
  59. /// - 180-200 = (2/7)*100 = 28.6%
  60. func calculateGlucoseRangeStatsForStackedChart(from ids: [NSManagedObjectID]) async {
  61. let taskContext = CoreDataStack.shared.newTaskContext()
  62. let calendar = Calendar.current
  63. let stats = await taskContext.perform {
  64. // Convert IDs to GlucoseStored objects using the context
  65. let readings = ids.compactMap { id -> GlucoseStored? in
  66. do {
  67. return try taskContext.existingObject(with: id) as? GlucoseStored
  68. } catch {
  69. debugPrint("\(DebuggingIdentifiers.failed) Error fetching glucose: \(error)")
  70. return nil
  71. }
  72. }
  73. // Count unique days for each hour
  74. let daysPerHour = (0 ... 23).map { hour in
  75. let uniqueDays = Set(readings.compactMap { reading -> Date? in
  76. guard let date = reading.date else { return nil }
  77. if calendar.component(.hour, from: date) == hour {
  78. return calendar.startOfDay(for: date)
  79. }
  80. return nil
  81. })
  82. return (hour: hour, days: uniqueDays.count)
  83. }
  84. // Define glucose ranges and their conditions
  85. // Ranges are processed from bottom to top in the stacked chart
  86. let ranges: [(name: String, condition: (Int) -> Bool)] = [
  87. ("<54", { g in g <= 54 }),
  88. ("54-\(self.timeInRangeType.bottomThreshold)", { g in g > 54 && g < self.timeInRangeType.bottomThreshold }),
  89. (
  90. "\(self.timeInRangeType.bottomThreshold)-\(self.timeInRangeType.topThreshold)",
  91. { g in g >= self.timeInRangeType.bottomThreshold && g <= self.timeInRangeType.topThreshold }
  92. ),
  93. ("\(self.timeInRangeType.topThreshold)-180", { g in g > self.timeInRangeType.topThreshold && g <= 180 }),
  94. ("180-200", { g in g > 180 && g <= 200 }),
  95. ("200-220", { g in g > 200 && g <= 220 }),
  96. (">220", { g in g > 220 })
  97. ]
  98. // Process each range to create the chart data
  99. return ranges.map { rangeName, condition in
  100. // Calculate values for each hour within this range
  101. let hourlyValues = (0 ... 23).map { hour in
  102. let totalDaysForHour = Double(daysPerHour[hour].days)
  103. // Skip if no data for this hour
  104. guard totalDaysForHour > 0 else { return (hour: hour, count: 0) }
  105. // Count readings that match the range condition for this hour
  106. let readingsInRange = readings.filter { reading in
  107. guard let date = reading.date else { return false }
  108. return calendar.component(.hour, from: date) == hour &&
  109. condition(Int(reading.glucose))
  110. }.count
  111. // Convert to percentage based on number of days with data
  112. let percentage = (Double(readingsInRange) / totalDaysForHour) * 100.0
  113. return (hour: hour, count: Int(percentage))
  114. }
  115. return GlucoseRangeStats(name: rangeName, values: hourlyValues)
  116. }
  117. }
  118. // Update stats on main thread
  119. await MainActor.run {
  120. self.glucoseRangeStats = stats
  121. }
  122. }
  123. }