BolusStatsView.swift 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import Charts
  2. import SwiftUI
  3. struct BolusStatsView: View {
  4. @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
  5. let bolusStats: [BolusStats]
  6. let calculateAverages: @Sendable(Date, Date) async -> (manual: Double, smb: Double, external: Double)
  7. @State private var scrollPosition = Date()
  8. @State private var selectedDate: Date?
  9. @State private var currentAverages: (manual: Double, smb: Double, external: Double) = (0, 0, 0)
  10. @State private var updateTimer = Stat.UpdateTimer()
  11. private var visibleDomainLength: TimeInterval {
  12. switch selectedDuration {
  13. case .Day: return 24 * 3600 // 1 day
  14. case .Week: return 7 * 24 * 3600 // 1 week
  15. case .Month: return 30 * 24 * 3600 // 1 month
  16. case .Total: return 90 * 24 * 3600 // 3 months
  17. }
  18. }
  19. private var visibleDateRange: (start: Date, end: Date) {
  20. let halfDomain = visibleDomainLength / 2
  21. let start = scrollPosition.addingTimeInterval(-halfDomain)
  22. let end = scrollPosition.addingTimeInterval(halfDomain)
  23. return (start, end)
  24. }
  25. private var dateFormat: Date.FormatStyle {
  26. switch selectedDuration {
  27. case .Day:
  28. return .dateTime.weekday(.abbreviated)
  29. case .Week:
  30. return .dateTime.weekday(.abbreviated)
  31. case .Month:
  32. return .dateTime.day()
  33. case .Total:
  34. return .dateTime.month(.abbreviated)
  35. }
  36. }
  37. private var alignmentComponents: DateComponents {
  38. switch selectedDuration {
  39. case .Day:
  40. return DateComponents(hour: 0) // Align to start of day
  41. case .Week:
  42. return DateComponents(weekday: 2) // 2 = Monday in Calendar
  43. case .Month,
  44. .Total:
  45. return DateComponents(day: 1) // Align to first day of month
  46. }
  47. }
  48. private func getBolusForDate(_ date: Date) -> BolusStats? {
  49. bolusStats.first { stat in
  50. Calendar.current.isDate(stat.date, inSameDayAs: date)
  51. }
  52. }
  53. private func updateAverages() {
  54. Task.detached(priority: .userInitiated) {
  55. let dateRange = await MainActor.run { visibleDateRange }
  56. let averages = await calculateAverages(dateRange.start, dateRange.end)
  57. await MainActor.run {
  58. currentAverages = averages
  59. }
  60. }
  61. }
  62. var body: some View {
  63. VStack(alignment: .leading, spacing: 8) {
  64. statsView
  65. Chart {
  66. ForEach(bolusStats) { stat in
  67. // External Bolus (Bottom)
  68. BarMark(
  69. x: .value("Date", stat.date, unit: .day),
  70. y: .value("Amount", stat.external)
  71. )
  72. .foregroundStyle(by: .value("Type", "External"))
  73. // SMB (Middle)
  74. BarMark(
  75. x: .value("Date", stat.date, unit: .day),
  76. y: .value("Amount", stat.smb)
  77. )
  78. .foregroundStyle(by: .value("Type", "SMB"))
  79. // Manual Bolus (Top)
  80. BarMark(
  81. x: .value("Date", stat.date, unit: .day),
  82. y: .value("Amount", stat.manualBolus)
  83. )
  84. .foregroundStyle(by: .value("Type", "Manual"))
  85. }
  86. if let selectedDate,
  87. let selectedBolus = getBolusForDate(selectedDate)
  88. {
  89. RuleMark(
  90. x: .value("Selected Date", selectedDate)
  91. )
  92. .foregroundStyle(.secondary.opacity(0.3))
  93. .annotation(
  94. position: .top,
  95. spacing: 0,
  96. overflowResolution: .init(x: .fit, y: .disabled)
  97. ) {
  98. BolusSelectionPopover(date: selectedDate, bolus: selectedBolus)
  99. }
  100. }
  101. }
  102. .chartForegroundStyleScale([
  103. "Manual": Color.teal,
  104. "SMB": Color.blue,
  105. "External": Color.purple
  106. ])
  107. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  108. .chartYAxis {
  109. AxisMarks(position: .trailing) { value in
  110. if let amount = value.as(Double.self) {
  111. AxisValueLabel {
  112. Text(amount.formatted(.number.precision(.fractionLength(1))) + " U")
  113. }
  114. AxisGridLine()
  115. }
  116. }
  117. }
  118. .chartXAxis {
  119. AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
  120. if let date = value.as(Date.self) {
  121. let day = Calendar.current.component(.day, from: date)
  122. switch selectedDuration {
  123. case .Month:
  124. if day % 5 == 0 { // Only show every 5th day
  125. AxisValueLabel(format: dateFormat)
  126. AxisGridLine()
  127. }
  128. case .Total:
  129. // Only show January, April, July, October
  130. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  131. AxisValueLabel(format: dateFormat)
  132. AxisGridLine()
  133. }
  134. default:
  135. AxisValueLabel(format: dateFormat)
  136. AxisGridLine()
  137. }
  138. }
  139. }
  140. }
  141. .chartXSelection(value: $selectedDate)
  142. .chartScrollableAxes(.horizontal)
  143. .chartScrollPosition(x: $scrollPosition)
  144. .chartScrollTargetBehavior(
  145. .valueAligned(
  146. matching: alignmentComponents,
  147. majorAlignment: .matching(alignmentComponents)
  148. )
  149. )
  150. .chartXVisibleDomain(length: visibleDomainLength)
  151. .frame(height: 200)
  152. }
  153. .onAppear {
  154. updateAverages()
  155. }
  156. .onChange(of: scrollPosition) {
  157. updateTimer.scheduleUpdate {
  158. updateAverages()
  159. }
  160. }
  161. .onChange(of: selectedDuration) {
  162. updateAverages()
  163. scrollPosition = Date()
  164. }
  165. }
  166. private var statsView: some View {
  167. HStack {
  168. Grid(alignment: .leading) {
  169. GridRow {
  170. Text("Manual:")
  171. .font(.headline)
  172. .foregroundStyle(.secondary)
  173. Text(currentAverages.manual.formatted(.number.precision(.fractionLength(1))))
  174. .font(.headline)
  175. .foregroundStyle(.secondary)
  176. .gridColumnAlignment(.trailing)
  177. Text("U")
  178. .font(.headline)
  179. .foregroundStyle(.secondary)
  180. }
  181. GridRow {
  182. Text("SMB:")
  183. .font(.headline)
  184. .foregroundStyle(.secondary)
  185. Text(currentAverages.smb.formatted(.number.precision(.fractionLength(1))))
  186. .font(.headline)
  187. .foregroundStyle(.secondary)
  188. .gridColumnAlignment(.trailing)
  189. Text("U")
  190. .font(.headline)
  191. .foregroundStyle(.secondary)
  192. }
  193. GridRow {
  194. Text("External:")
  195. .font(.headline)
  196. .foregroundStyle(.secondary)
  197. Text(currentAverages.external.formatted(.number.precision(.fractionLength(1))))
  198. .font(.headline)
  199. .foregroundStyle(.secondary)
  200. .gridColumnAlignment(.trailing)
  201. Text("U")
  202. .font(.headline)
  203. .foregroundStyle(.secondary)
  204. }
  205. }
  206. Spacer()
  207. Text(
  208. "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
  209. )
  210. .font(.subheadline)
  211. .foregroundStyle(.secondary)
  212. }
  213. }
  214. }
  215. private struct BolusSelectionPopover: View {
  216. let date: Date
  217. let bolus: BolusStats
  218. var body: some View {
  219. VStack(alignment: .leading, spacing: 4) {
  220. Text(date.formatted(.dateTime.month().day()))
  221. .font(.caption)
  222. .foregroundStyle(.secondary)
  223. Grid(alignment: .leading) {
  224. GridRow {
  225. Text("Manual:")
  226. Text(bolus.manualBolus.formatted(.number.precision(.fractionLength(1))))
  227. .gridColumnAlignment(.trailing)
  228. Text("U")
  229. }
  230. GridRow {
  231. Text("SMB:")
  232. Text(bolus.smb.formatted(.number.precision(.fractionLength(1))))
  233. .gridColumnAlignment(.trailing)
  234. Text("U")
  235. }
  236. GridRow {
  237. Text("External:")
  238. Text(bolus.external.formatted(.number.precision(.fractionLength(1))))
  239. .gridColumnAlignment(.trailing)
  240. Text("U")
  241. }
  242. }
  243. .font(.caption)
  244. }
  245. .padding(8)
  246. .background(
  247. RoundedRectangle(cornerRadius: 8)
  248. .fill(Color(.systemBackground))
  249. .shadow(radius: 2)
  250. )
  251. }
  252. }