BolusStatsView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  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. private func isSameTimeUnit(_ date1: Date, _ date2: Date) -> Bool {
  102. switch selectedDuration {
  103. case .Day:
  104. return Calendar.current.isDate(date1, equalTo: date2, toGranularity: .hour)
  105. default:
  106. return Calendar.current.isDate(date1, inSameDayAs: date2)
  107. }
  108. }
  109. /// Returns the initial scroll position date based on the selected duration
  110. private func getInitialScrollPosition() -> Date {
  111. let calendar = Calendar.current
  112. let now = Date()
  113. switch selectedDuration {
  114. case .Day:
  115. return calendar.date(byAdding: .day, value: -1, to: now)!
  116. case .Week:
  117. return calendar.date(byAdding: .day, value: -7, to: now)!
  118. case .Month:
  119. return calendar.date(byAdding: .month, value: -1, to: now)!
  120. case .Total:
  121. return calendar.date(byAdding: .month, value: -3, to: now)!
  122. }
  123. }
  124. var body: some View {
  125. VStack(alignment: .leading, spacing: 8) {
  126. statsView
  127. chartsView
  128. }
  129. .onAppear {
  130. scrollPosition = getInitialScrollPosition()
  131. updateAverages()
  132. }
  133. .onChange(of: scrollPosition) {
  134. updateTimer.scheduleUpdate {
  135. updateAverages()
  136. }
  137. }
  138. .onChange(of: selectedDuration) {
  139. Task {
  140. scrollPosition = getInitialScrollPosition()
  141. updateAverages()
  142. }
  143. }
  144. }
  145. private var statsView: some View {
  146. HStack {
  147. Grid(alignment: .leading) {
  148. GridRow {
  149. Text("Manual:")
  150. .font(.headline)
  151. .foregroundStyle(.secondary)
  152. Text(currentAverages.manual.formatted(.number.precision(.fractionLength(1))))
  153. .font(.headline)
  154. .foregroundStyle(.secondary)
  155. .gridColumnAlignment(.trailing)
  156. Text("U")
  157. .font(.headline)
  158. .foregroundStyle(.secondary)
  159. }
  160. GridRow {
  161. Text("SMB:")
  162. .font(.headline)
  163. .foregroundStyle(.secondary)
  164. Text(currentAverages.smb.formatted(.number.precision(.fractionLength(1))))
  165. .font(.headline)
  166. .foregroundStyle(.secondary)
  167. .gridColumnAlignment(.trailing)
  168. Text("U")
  169. .font(.headline)
  170. .foregroundStyle(.secondary)
  171. }
  172. GridRow {
  173. Text("External:")
  174. .font(.headline)
  175. .foregroundStyle(.secondary)
  176. Text(currentAverages.external.formatted(.number.precision(.fractionLength(1))))
  177. .font(.headline)
  178. .foregroundStyle(.secondary)
  179. .gridColumnAlignment(.trailing)
  180. Text("U")
  181. .font(.headline)
  182. .foregroundStyle(.secondary)
  183. }
  184. }
  185. Spacer()
  186. Text(formatVisibleDateRange())
  187. .font(.subheadline)
  188. .foregroundStyle(.secondary)
  189. }
  190. }
  191. private var chartsView: some View {
  192. Chart {
  193. ForEach(bolusStats) { stat in
  194. // Total Bolus Bar
  195. BarMark(
  196. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  197. y: .value("Amount", stat.manualBolus)
  198. )
  199. .foregroundStyle(by: .value("Type", "Manual"))
  200. .position(by: .value("Type", "Boluses"))
  201. .opacity(
  202. selectedDate.map { date in
  203. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  204. } ?? 1
  205. )
  206. // Carb Bolus Bar
  207. BarMark(
  208. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  209. y: .value("Amount", stat.smb)
  210. )
  211. .foregroundStyle(by: .value("Type", "SMB"))
  212. .position(by: .value("Type", "Boluses"))
  213. .opacity(
  214. selectedDate.map { date in
  215. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  216. } ?? 1
  217. )
  218. // Correction Bolus Bar
  219. BarMark(
  220. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  221. y: .value("Amount", stat.external)
  222. )
  223. .foregroundStyle(by: .value("Type", "External"))
  224. .position(by: .value("Type", "Boluses"))
  225. .opacity(
  226. selectedDate.map { date in
  227. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  228. } ?? 1
  229. )
  230. }
  231. // Selection popover outside of the ForEach loop!
  232. if let selectedDate, let selectedBolus = getBolusForDate(selectedDate)
  233. {
  234. RuleMark(
  235. x: .value("Selected Date", selectedDate)
  236. )
  237. .foregroundStyle(.secondary.opacity(0.3))
  238. .annotation(
  239. position: .top,
  240. spacing: 0,
  241. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  242. ) {
  243. BolusSelectionPopover(date: selectedDate, bolus: selectedBolus)
  244. }
  245. }
  246. }
  247. .chartForegroundStyleScale([
  248. "SMB": Color.blue,
  249. "Manual": Color.teal,
  250. "External": Color.purple
  251. ])
  252. .chartLegend(position: .bottom, alignment: .leading, spacing: 12)
  253. .chartYAxis {
  254. AxisMarks(position: .trailing) { value in
  255. if let amount = value.as(Double.self) {
  256. AxisValueLabel {
  257. Text(amount.formatted(.number.precision(.fractionLength(0))) + " U")
  258. }
  259. AxisGridLine()
  260. }
  261. }
  262. }
  263. .chartXAxis {
  264. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  265. if let date = value.as(Date.self) {
  266. let day = Calendar.current.component(.day, from: date)
  267. let hour = Calendar.current.component(.hour, from: date)
  268. switch selectedDuration {
  269. case .Day:
  270. if hour % 6 == 0 { // Show only every 6 hours
  271. AxisValueLabel(format: dateFormat, centered: true)
  272. AxisGridLine()
  273. }
  274. case .Month:
  275. if day % 5 == 0 { // Only show every 5th day
  276. AxisValueLabel(format: dateFormat, centered: true)
  277. AxisGridLine()
  278. }
  279. case .Total:
  280. // Only show January, April, July, October
  281. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  282. AxisValueLabel(format: dateFormat, centered: true)
  283. AxisGridLine()
  284. }
  285. default:
  286. AxisValueLabel(format: dateFormat, centered: true)
  287. AxisGridLine()
  288. }
  289. }
  290. }
  291. }
  292. .chartScrollableAxes(.horizontal)
  293. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  294. .chartScrollPosition(x: $scrollPosition)
  295. .chartScrollTargetBehavior(
  296. .valueAligned(
  297. matching: selectedDuration == .Day ?
  298. DateComponents(minute: 0) : // Align to next hour for Day view
  299. DateComponents(hour: 0), // Align to start of day for other views
  300. majorAlignment: .matching(
  301. alignmentComponents
  302. )
  303. )
  304. )
  305. .chartXVisibleDomain(length: visibleDomainLength)
  306. .frame(height: 250)
  307. }
  308. }
  309. private struct BolusSelectionPopover: View {
  310. let date: Date
  311. let bolus: BolusStats
  312. var body: some View {
  313. VStack(alignment: .leading, spacing: 4) {
  314. Text(date.formatted(.dateTime.month().day()))
  315. .font(.caption)
  316. .foregroundStyle(.secondary)
  317. Grid(alignment: .leading) {
  318. GridRow {
  319. Text("Manual:")
  320. Text(bolus.manualBolus.formatted(.number.precision(.fractionLength(1))))
  321. .gridColumnAlignment(.trailing)
  322. Text("U")
  323. }
  324. GridRow {
  325. Text("SMB:")
  326. Text(bolus.smb.formatted(.number.precision(.fractionLength(1))))
  327. .gridColumnAlignment(.trailing)
  328. Text("U")
  329. }
  330. GridRow {
  331. Text("External:")
  332. Text(bolus.external.formatted(.number.precision(.fractionLength(1))))
  333. .gridColumnAlignment(.trailing)
  334. Text("U")
  335. }
  336. }
  337. .font(.caption)
  338. }
  339. .padding(8)
  340. .background(
  341. RoundedRectangle(cornerRadius: 8)
  342. .fill(Color(.systemBackground))
  343. .shadow(radius: 2)
  344. )
  345. }
  346. }