BolusStatsView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import Charts
  2. import SwiftUI
  3. struct BolusStatsView: View {
  4. @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
  5. let bolusStats: [BolusStats]
  6. let state: Stat.StateModel
  7. @State private var scrollPosition = Date() // gets updated in onAppear block
  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. /// Returns the time interval length for the visible domain based on selected duration
  12. private var visibleDomainLength: TimeInterval {
  13. switch selectedDuration {
  14. case .Day: return 24 * 3600 // One day in seconds
  15. case .Week: return 7 * 24 * 3600 // One week in seconds
  16. case .Month: return 30 * 24 * 3600 // One month in seconds
  17. case .Total: return 90 * 24 * 3600 // Three months in seconds
  18. }
  19. }
  20. /// Calculates the visible date range based on scroll position and domain length
  21. private var visibleDateRange: (start: Date, end: Date) {
  22. let start = scrollPosition // Current scroll position marks the start
  23. let end = start.addingTimeInterval(visibleDomainLength)
  24. return (start, end)
  25. }
  26. /// Returns the appropriate date format style based on the selected time interval
  27. private var dateFormat: Date.FormatStyle {
  28. switch selectedDuration {
  29. case .Day:
  30. return .dateTime.hour()
  31. case .Week:
  32. return .dateTime.weekday(.abbreviated)
  33. case .Month:
  34. return .dateTime.day()
  35. case .Total:
  36. return .dateTime.month(.abbreviated)
  37. }
  38. }
  39. /// Returns DateComponents for aligning dates based on the selected duration
  40. private var alignmentComponents: DateComponents {
  41. switch selectedDuration {
  42. case .Day:
  43. return DateComponents(hour: 0) // Align to midnight
  44. case .Week:
  45. return DateComponents(weekday: 2) // Monday is weekday 2
  46. case .Month,
  47. .Total:
  48. return DateComponents(day: 1) // First day of month
  49. }
  50. }
  51. /// Returns bolus statistics for a specific date
  52. private func getBolusForDate(_ date: Date) -> BolusStats? {
  53. bolusStats.first { stat in
  54. Calendar.current.isDate(stat.date, inSameDayAs: date)
  55. }
  56. }
  57. /// Updates the current averages for bolus insulin based on the visible date range
  58. private func updateAverages() {
  59. currentAverages = state.getCachedBolusAverages(for: visibleDateRange)
  60. }
  61. /// Formats the visible date range into a human-readable string
  62. private func formatVisibleDateRange() -> String {
  63. let start = visibleDateRange.start
  64. let end = visibleDateRange.end
  65. let calendar = Calendar.current
  66. let today = Date()
  67. let timeFormat = start.formatted(.dateTime.hour().minute())
  68. // Special handling for Day view with relative dates
  69. if selectedDuration == .Day {
  70. let startDateText: String
  71. let endDateText: String
  72. // Format start date
  73. if calendar.isDate(start, inSameDayAs: today) {
  74. startDateText = "Today"
  75. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  76. startDateText = "Yesterday"
  77. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  78. startDateText = "Tomorrow"
  79. } else {
  80. startDateText = start.formatted(.dateTime.day().month())
  81. }
  82. // Format end date
  83. if calendar.isDate(end, inSameDayAs: today) {
  84. endDateText = "Today"
  85. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  86. endDateText = "Yesterday"
  87. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  88. endDateText = "Tomorrow"
  89. } else {
  90. endDateText = end.formatted(.dateTime.day().month())
  91. }
  92. // If start and end are on the same day, show date only once
  93. if calendar.isDate(start, inSameDayAs: end) {
  94. return "\(startDateText), \(timeFormat) - \(end.formatted(.dateTime.hour().minute()))"
  95. }
  96. return "\(startDateText), \(timeFormat) - \(endDateText), \(end.formatted(.dateTime.hour().minute()))"
  97. }
  98. // Standard format for other views
  99. return "\(start.formatted()) - \(end.formatted())"
  100. }
  101. /// Returns the initial scroll position date based on the selected duration
  102. private func getInitialScrollPosition() -> Date {
  103. let calendar = Calendar.current
  104. let now = Date()
  105. switch selectedDuration {
  106. case .Day:
  107. return calendar.date(byAdding: .day, value: -1, to: now)!
  108. case .Week:
  109. return calendar.date(byAdding: .day, value: -7, to: now)!
  110. case .Month:
  111. return calendar.date(byAdding: .month, value: -1, to: now)!
  112. case .Total:
  113. return calendar.date(byAdding: .month, value: -3, to: now)!
  114. }
  115. }
  116. var body: some View {
  117. VStack(alignment: .leading, spacing: 8) {
  118. statsView
  119. chartsView
  120. }
  121. .onAppear {
  122. scrollPosition = getInitialScrollPosition()
  123. updateAverages()
  124. }
  125. .onChange(of: scrollPosition) {
  126. updateTimer.scheduleUpdate {
  127. updateAverages()
  128. }
  129. }
  130. .onChange(of: selectedDuration) {
  131. Task {
  132. scrollPosition = getInitialScrollPosition()
  133. updateAverages()
  134. }
  135. }
  136. }
  137. private var statsView: some View {
  138. HStack {
  139. Grid(alignment: .leading) {
  140. GridRow {
  141. Text("Manual:")
  142. .font(.headline)
  143. .foregroundStyle(.secondary)
  144. Text(currentAverages.manual.formatted(.number.precision(.fractionLength(1))))
  145. .font(.headline)
  146. .foregroundStyle(.secondary)
  147. .gridColumnAlignment(.trailing)
  148. Text("U")
  149. .font(.headline)
  150. .foregroundStyle(.secondary)
  151. }
  152. GridRow {
  153. Text("SMB:")
  154. .font(.headline)
  155. .foregroundStyle(.secondary)
  156. Text(currentAverages.smb.formatted(.number.precision(.fractionLength(1))))
  157. .font(.headline)
  158. .foregroundStyle(.secondary)
  159. .gridColumnAlignment(.trailing)
  160. Text("U")
  161. .font(.headline)
  162. .foregroundStyle(.secondary)
  163. }
  164. GridRow {
  165. Text("External:")
  166. .font(.headline)
  167. .foregroundStyle(.secondary)
  168. Text(currentAverages.external.formatted(.number.precision(.fractionLength(1))))
  169. .font(.headline)
  170. .foregroundStyle(.secondary)
  171. .gridColumnAlignment(.trailing)
  172. Text("U")
  173. .font(.headline)
  174. .foregroundStyle(.secondary)
  175. }
  176. }
  177. Spacer()
  178. Text(formatVisibleDateRange())
  179. .font(.subheadline)
  180. .foregroundStyle(.secondary)
  181. }
  182. }
  183. private var chartsView: some View {
  184. Chart(bolusStats) { stat in
  185. // Total Bolus Bar
  186. BarMark(
  187. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  188. y: .value("Amount", stat.manualBolus)
  189. )
  190. .foregroundStyle(by: .value("Type", "Manual"))
  191. .position(by: .value("Type", "Manual"))
  192. // Carb Bolus Bar
  193. BarMark(
  194. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  195. y: .value("Amount", stat.smb)
  196. )
  197. .foregroundStyle(by: .value("Type", "SMB"))
  198. .position(by: .value("Type", "SMB"))
  199. // Correction Bolus Bar
  200. BarMark(
  201. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  202. y: .value("Amount", stat.external)
  203. )
  204. .foregroundStyle(by: .value("Type", "External"))
  205. .position(by: .value("Type", "External"))
  206. if let selectedDate,
  207. let selectedBolus = getBolusForDate(selectedDate)
  208. {
  209. RuleMark(
  210. x: .value("Selected Date", selectedDate)
  211. )
  212. .foregroundStyle(.secondary.opacity(0.3))
  213. .annotation(
  214. position: .top,
  215. spacing: 0,
  216. overflowResolution: .init(x: .fit, y: .disabled)
  217. ) {
  218. BolusSelectionPopover(date: selectedDate, bolus: selectedBolus)
  219. }
  220. }
  221. }
  222. .chartForegroundStyleScale([
  223. "Manual": Color.teal,
  224. "SMB": Color.blue,
  225. "External": Color.purple
  226. ])
  227. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  228. .chartYAxis {
  229. AxisMarks(position: .trailing) { value in
  230. if let amount = value.as(Double.self) {
  231. AxisValueLabel {
  232. Text(amount.formatted(.number.precision(.fractionLength(0))) + " U")
  233. }
  234. AxisGridLine()
  235. }
  236. }
  237. }
  238. .chartXAxis {
  239. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  240. if let date = value.as(Date.self) {
  241. let day = Calendar.current.component(.day, from: date)
  242. let hour = Calendar.current.component(.hour, from: date)
  243. switch selectedDuration {
  244. case .Day:
  245. if hour % 6 == 0 { // Show only every 6 hours
  246. AxisValueLabel(format: dateFormat, centered: true)
  247. AxisGridLine()
  248. }
  249. case .Month:
  250. if day % 5 == 0 { // Only show every 5th day
  251. AxisValueLabel(format: dateFormat, centered: true)
  252. AxisGridLine()
  253. }
  254. case .Total:
  255. // Only show January, April, July, October
  256. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  257. AxisValueLabel(format: dateFormat, centered: true)
  258. AxisGridLine()
  259. }
  260. default:
  261. AxisValueLabel(format: dateFormat, centered: true)
  262. AxisGridLine()
  263. }
  264. }
  265. }
  266. }
  267. .chartXSelection(value: $selectedDate)
  268. .chartScrollableAxes(.horizontal)
  269. .chartScrollPosition(x: $scrollPosition)
  270. .chartScrollTargetBehavior(
  271. .valueAligned(
  272. matching: selectedDuration == .Day ?
  273. DateComponents(minute: 0) : // Align to next hour for Day view
  274. DateComponents(hour: 0), // Align to start of day for other views
  275. majorAlignment: .matching(
  276. alignmentComponents
  277. )
  278. )
  279. )
  280. .chartXVisibleDomain(length: visibleDomainLength)
  281. .frame(height: 200)
  282. }
  283. }
  284. private struct BolusSelectionPopover: View {
  285. let date: Date
  286. let bolus: BolusStats
  287. var body: some View {
  288. VStack(alignment: .leading, spacing: 4) {
  289. Text(date.formatted(.dateTime.month().day()))
  290. .font(.caption)
  291. .foregroundStyle(.secondary)
  292. Grid(alignment: .leading) {
  293. GridRow {
  294. Text("Manual:")
  295. Text(bolus.manualBolus.formatted(.number.precision(.fractionLength(1))))
  296. .gridColumnAlignment(.trailing)
  297. Text("U")
  298. }
  299. GridRow {
  300. Text("SMB:")
  301. Text(bolus.smb.formatted(.number.precision(.fractionLength(1))))
  302. .gridColumnAlignment(.trailing)
  303. Text("U")
  304. }
  305. GridRow {
  306. Text("External:")
  307. Text(bolus.external.formatted(.number.precision(.fractionLength(1))))
  308. .gridColumnAlignment(.trailing)
  309. Text("U")
  310. }
  311. }
  312. .font(.caption)
  313. }
  314. .padding(8)
  315. .background(
  316. RoundedRectangle(cornerRadius: 8)
  317. .fill(Color(.systemBackground))
  318. .shadow(radius: 2)
  319. )
  320. }
  321. }