BolusStatsView.swift 12 KB

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