BolusStatsView.swift 16 KB

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