BolusStatsView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  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. chartsView
  92. }
  93. .onAppear {
  94. updateAverages()
  95. }
  96. .onChange(of: scrollPosition) {
  97. isScrolling = true
  98. updateTimer.scheduleUpdate {
  99. updateAverages()
  100. isScrolling = false
  101. }
  102. }
  103. .onChange(of: selectedDuration) {
  104. updateAverages()
  105. scrollPosition = Date()
  106. }
  107. }
  108. private var statsView: some View {
  109. HStack {
  110. Grid(alignment: .leading) {
  111. GridRow {
  112. Text("Manual:")
  113. .font(.headline)
  114. .foregroundStyle(.secondary)
  115. Text(currentAverages.manual.formatted(.number.precision(.fractionLength(1))))
  116. .font(.headline)
  117. .foregroundStyle(.secondary)
  118. .gridColumnAlignment(.trailing)
  119. Text("U")
  120. .font(.headline)
  121. .foregroundStyle(.secondary)
  122. }
  123. GridRow {
  124. Text("SMB:")
  125. .font(.headline)
  126. .foregroundStyle(.secondary)
  127. Text(currentAverages.smb.formatted(.number.precision(.fractionLength(1))))
  128. .font(.headline)
  129. .foregroundStyle(.secondary)
  130. .gridColumnAlignment(.trailing)
  131. Text("U")
  132. .font(.headline)
  133. .foregroundStyle(.secondary)
  134. }
  135. GridRow {
  136. Text("External:")
  137. .font(.headline)
  138. .foregroundStyle(.secondary)
  139. Text(currentAverages.external.formatted(.number.precision(.fractionLength(1))))
  140. .font(.headline)
  141. .foregroundStyle(.secondary)
  142. .gridColumnAlignment(.trailing)
  143. Text("U")
  144. .font(.headline)
  145. .foregroundStyle(.secondary)
  146. }
  147. }
  148. Spacer()
  149. Text(formatVisibleDateRange(showTimeRange: isScrolling))
  150. .font(.subheadline)
  151. .foregroundStyle(.secondary)
  152. }
  153. }
  154. private var chartsView: some View {
  155. Chart {
  156. ForEach(bolusStats) { stat in
  157. // Manual Bolus (Bottom)
  158. BarMark(
  159. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  160. y: .value("Amount", stat.manualBolus)
  161. )
  162. .foregroundStyle(by: .value("Type", "Manual"))
  163. // SMB (Middle)
  164. BarMark(
  165. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  166. y: .value("Amount", stat.smb)
  167. )
  168. .foregroundStyle(by: .value("Type", "SMB"))
  169. // External (Top)
  170. BarMark(
  171. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  172. y: .value("Amount", stat.external)
  173. )
  174. .foregroundStyle(by: .value("Type", "External"))
  175. }
  176. if let selectedDate,
  177. let selectedBolus = getBolusForDate(selectedDate)
  178. {
  179. RuleMark(
  180. x: .value("Selected Date", selectedDate)
  181. )
  182. .foregroundStyle(.secondary.opacity(0.3))
  183. .annotation(
  184. position: .top,
  185. spacing: 0,
  186. overflowResolution: .init(x: .fit, y: .disabled)
  187. ) {
  188. BolusSelectionPopover(date: selectedDate, bolus: selectedBolus)
  189. }
  190. }
  191. }
  192. .chartForegroundStyleScale([
  193. "Manual": Color.teal,
  194. "SMB": Color.blue,
  195. "External": Color.purple
  196. ])
  197. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  198. .chartYAxis {
  199. AxisMarks(position: .trailing) { value in
  200. if let amount = value.as(Double.self) {
  201. AxisValueLabel {
  202. Text(amount.formatted(.number.precision(.fractionLength(0))) + " U")
  203. }
  204. AxisGridLine()
  205. }
  206. }
  207. }
  208. .chartXAxis {
  209. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  210. if let date = value.as(Date.self) {
  211. let day = Calendar.current.component(.day, from: date)
  212. let hour = Calendar.current.component(.hour, from: date)
  213. switch selectedDuration {
  214. case .Day:
  215. if hour % 6 == 0 { // Show only every 6 hours (0, 6, 12, 18)
  216. AxisValueLabel(format: dateFormat, centered: true)
  217. AxisGridLine()
  218. }
  219. case .Month:
  220. if day % 5 == 0 { // Only show every 5th day
  221. AxisValueLabel(format: dateFormat, centered: true)
  222. AxisGridLine()
  223. }
  224. case .Total:
  225. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  226. AxisValueLabel(format: dateFormat, centered: true)
  227. AxisGridLine()
  228. }
  229. default:
  230. AxisValueLabel(format: dateFormat, centered: true)
  231. AxisGridLine()
  232. }
  233. }
  234. }
  235. }
  236. .chartXSelection(value: $selectedDate)
  237. .chartScrollableAxes(.horizontal)
  238. .chartScrollPosition(x: $scrollPosition)
  239. .chartScrollTargetBehavior(
  240. .valueAligned(
  241. matching: selectedDuration == .Day ?
  242. DateComponents(minute: 0) : // Align to next hour for Day view
  243. DateComponents(hour: 0), // Align to start of day for other views
  244. majorAlignment: .matching(alignmentComponents)
  245. )
  246. )
  247. .chartXVisibleDomain(length: visibleDomainLength)
  248. .frame(height: 200)
  249. }
  250. }
  251. private struct BolusSelectionPopover: View {
  252. let date: Date
  253. let bolus: BolusStats
  254. var body: some View {
  255. VStack(alignment: .leading, spacing: 4) {
  256. Text(date.formatted(.dateTime.month().day()))
  257. .font(.caption)
  258. .foregroundStyle(.secondary)
  259. Grid(alignment: .leading) {
  260. GridRow {
  261. Text("Manual:")
  262. Text(bolus.manualBolus.formatted(.number.precision(.fractionLength(1))))
  263. .gridColumnAlignment(.trailing)
  264. Text("U")
  265. }
  266. GridRow {
  267. Text("SMB:")
  268. Text(bolus.smb.formatted(.number.precision(.fractionLength(1))))
  269. .gridColumnAlignment(.trailing)
  270. Text("U")
  271. }
  272. GridRow {
  273. Text("External:")
  274. Text(bolus.external.formatted(.number.precision(.fractionLength(1))))
  275. .gridColumnAlignment(.trailing)
  276. Text("U")
  277. }
  278. }
  279. .font(.caption)
  280. }
  281. .padding(8)
  282. .background(
  283. RoundedRectangle(cornerRadius: 8)
  284. .fill(Color(.systemBackground))
  285. .shadow(radius: 2)
  286. )
  287. }
  288. }