BolusStatsView.swift 15 KB

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