TotalDailyDoseChart.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import Charts
  2. import SwiftUI
  3. /// A view that displays a bar chart for Total Daily Dose (TDD) statistics.
  4. ///
  5. /// This view presents insulin usage over time, with the ability to adjust the time interval
  6. /// and scroll through historical data.
  7. struct TotalDailyDoseChart: View {
  8. /// The selected time interval for displaying statistics.
  9. @Binding var selectedInterval: Stat.StateModel.StatsTimeInterval
  10. /// The list of TDD statistics data.
  11. let tddStats: [TDDStats]
  12. /// The state model containing cached statistics data.
  13. let state: Stat.StateModel
  14. /// The current scroll position in the chart.
  15. @State private var scrollPosition = Date()
  16. /// The currently selected date in the chart.
  17. @State private var selectedDate: Date?
  18. /// The calculated average TDD for the visible range.
  19. @State private var currentAverage: Double = 0
  20. /// Timer to throttle updates when scrolling.
  21. @State private var updateTimer = Stat.UpdateTimer()
  22. /// Sum of hourly doses for `Day` view
  23. @State private var sumOfHourlyDoses: Double = 0
  24. /// The actual chart plot's width in pixel
  25. @State private var chartWidth: CGFloat = 0
  26. /// Computes the visible date range based on the current scroll position.
  27. private var visibleDateRange: (start: Date, end: Date) {
  28. StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedInterval)
  29. }
  30. /// Retrieves the TDD statistic for a given date.
  31. /// - Parameter date: The date for which to retrieve TDD data.
  32. /// - Returns: The `TDDStats` object if available, otherwise `nil`.
  33. private func getTDDForDate(_ date: Date) -> TDDStats? {
  34. tddStats.first { stat in
  35. StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedInterval)
  36. }
  37. }
  38. /// Updates the average TDD value based on the visible date range.
  39. private func updateAverages() {
  40. currentAverage = state.getCachedTDDAverages(for: visibleDateRange)
  41. }
  42. /// Updates the total of hourly doses for `Day` view
  43. private func updateTotalDoses() {
  44. sumOfHourlyDoses = tddStats.filter({ $0.date >= visibleDateRange.start && $0.date <= visibleDateRange.end })
  45. .reduce(0, { result, stat in
  46. result + stat.amount
  47. })
  48. }
  49. var body: some View {
  50. VStack(alignment: .leading, spacing: 8) {
  51. statsView.padding(.bottom)
  52. VStack(alignment: .trailing) {
  53. Text("Total Daily Dose (U)")
  54. .foregroundStyle(.secondary)
  55. .font(.footnote)
  56. .padding(.bottom, 4)
  57. chartsView
  58. .background(
  59. GeometryReader { geo in
  60. Color.clear
  61. .onAppear { chartWidth = geo.size.width }
  62. .onChange(of: geo.size.width) { _, newValue in chartWidth = newValue }
  63. }
  64. )
  65. }
  66. }
  67. .onAppear {
  68. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
  69. // Delay the initial update to ensure scroll position has been processed
  70. DispatchQueue.main.async {
  71. updateAverages()
  72. updateTotalDoses()
  73. }
  74. }
  75. .onChange(of: scrollPosition) {
  76. updateTimer.scheduleUpdate {
  77. updateAverages()
  78. if selectedInterval == .day {
  79. updateTotalDoses()
  80. }
  81. }
  82. }
  83. .onChange(of: selectedInterval) {
  84. Task {
  85. scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
  86. // Use async dispatch to ensure scroll position is updated before calculating averages
  87. await MainActor.run {
  88. updateAverages()
  89. if selectedInterval == .day {
  90. updateTotalDoses()
  91. }
  92. }
  93. }
  94. }
  95. }
  96. /// A view displaying the statistics summary including average TDD.
  97. private var statsView: some View {
  98. HStack {
  99. if selectedInterval == .day {
  100. Grid(alignment: .leading) {
  101. GridRow {
  102. Text("Average:")
  103. Text(currentAverage.formatted(.number.precision(.fractionLength(1))))
  104. + Text("\u{00A0}") + Text("U")
  105. }
  106. GridRow {
  107. Text("Total:")
  108. Text(sumOfHourlyDoses.formatted(.number.precision(.fractionLength(1))))
  109. + Text("\u{00A0}") + Text("U")
  110. }
  111. }
  112. .font(.headline)
  113. } else {
  114. Group {
  115. Text("Average:")
  116. Text(currentAverage.formatted(.number.precision(.fractionLength(1))))
  117. + Text("\u{00A0}") + Text("U")
  118. }
  119. .font(.headline)
  120. }
  121. Spacer()
  122. Text(
  123. StatChartUtils
  124. .formatVisibleDateRange(from: visibleDateRange.start, to: visibleDateRange.end, for: selectedInterval)
  125. )
  126. .font(.callout)
  127. .foregroundStyle(.secondary)
  128. }
  129. }
  130. /// A view displaying the bar chart for TDD statistics.
  131. private var chartsView: some View {
  132. Chart {
  133. ForEach(tddStats) { stat in
  134. BarMark(
  135. x: .value("Date", stat.date, unit: selectedInterval == .day ? .hour : .day),
  136. y: .value("Amount", stat.amount)
  137. )
  138. .foregroundStyle(Color.insulin)
  139. .opacity(
  140. selectedDate.map { date in
  141. StatChartUtils.isSameTimeUnit(stat.date, date, for: selectedInterval) ? 1 : 0.3
  142. } ?? 1
  143. )
  144. }
  145. // Selection popover outside of the ForEach loop!
  146. if let selectedDate,
  147. let selectedTDD = getTDDForDate(selectedDate)
  148. {
  149. RuleMark(
  150. x: .value("Selected Date", selectedDate)
  151. )
  152. .foregroundStyle(Color.insulin.opacity(0.5))
  153. .annotation(
  154. position: .top,
  155. spacing: 0,
  156. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  157. ) {
  158. TDDSelectionPopover(
  159. selectedDate: selectedDate,
  160. tdd: selectedTDD,
  161. selectedInterval: selectedInterval,
  162. domain: visibleDateRange,
  163. chartWidth: chartWidth
  164. )
  165. }
  166. }
  167. // Dummy PointMark to force SwiftCharts to render a visible domain of 00:00-23:59
  168. // i.e. single day from midnight to midnight
  169. if selectedInterval == .day {
  170. let calendar = Calendar.current
  171. let midnight = calendar.startOfDay(for: Date())
  172. let nextMidnight = calendar.date(byAdding: .day, value: 1, to: midnight)!
  173. PointMark(
  174. x: .value("Time", nextMidnight),
  175. y: .value("Dummy", 0)
  176. )
  177. .opacity(0) // ensures dummy ChartContent is hidden
  178. }
  179. }
  180. .chartYAxis {
  181. AxisMarks(position: .trailing) { value in
  182. if let amount = value.as(Double.self) {
  183. AxisValueLabel {
  184. Text(amount.formatted(.number.precision(.fractionLength(0))))
  185. .font(.footnote)
  186. }
  187. AxisGridLine()
  188. }
  189. }
  190. }
  191. .chartXAxis {
  192. AxisMarks(preset: .aligned, values: .stride(by: selectedInterval == .day ? .hour : .day)) { value in
  193. if let date = value.as(Date.self) {
  194. let day = Calendar.current.component(.day, from: date)
  195. let hour = Calendar.current.component(.hour, from: date)
  196. switch selectedInterval {
  197. case .day:
  198. if hour % 6 == 0 { // Show only every 6 hours
  199. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
  200. .font(.footnote)
  201. AxisGridLine()
  202. }
  203. case .month:
  204. let weekday = calendar.component(.weekday, from: date)
  205. if weekday == calendar.firstWeekday { // Only show the first day of the week
  206. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
  207. .font(.footnote)
  208. AxisGridLine()
  209. }
  210. case .total:
  211. // Only show every other month
  212. if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 {
  213. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
  214. .font(.footnote)
  215. AxisGridLine()
  216. }
  217. default:
  218. AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true)
  219. .font(.footnote)
  220. AxisGridLine()
  221. }
  222. }
  223. }
  224. }
  225. .chartScrollableAxes(.horizontal)
  226. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  227. .chartScrollPosition(x: $scrollPosition)
  228. .chartScrollTargetBehavior(
  229. .valueAligned(
  230. matching: selectedInterval == .day ?
  231. DateComponents(minute: 0) :
  232. DateComponents(hour: 0),
  233. majorAlignment: .matching(StatChartUtils.alignmentComponents(for: selectedInterval))
  234. )
  235. )
  236. .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedInterval))
  237. .frame(height: 250)
  238. }
  239. }
  240. /// A popover view displaying TDD (Total Daily Dose) for a given time period.
  241. /// Shows the insulin amount in units (U) for an hourly or daily interval, depending on `selectedInterval`.
  242. ///
  243. /// - Parameters:
  244. /// - date: The reference date for determining the displayed time range.
  245. /// - tdd: The TDDStats containing insulin usage data.
  246. /// - selectedInterval: The selected time interval (hourly or daily).
  247. private struct TDDSelectionPopover: View {
  248. let selectedDate: Date
  249. let tdd: TDDStats
  250. let selectedInterval: Stat.StateModel.StatsTimeInterval
  251. let domain: (start: Date, end: Date)
  252. let chartWidth: CGFloat
  253. @State private var popoverSize: CGSize = .zero
  254. @Environment(\.colorScheme) var colorScheme
  255. private var timeText: String {
  256. if selectedInterval == .day {
  257. let hour = Calendar.current.component(.hour, from: selectedDate)
  258. return selectedDate.formatted(.dateTime.month().day().weekday()) + "\n" + "\(hour):00-\(hour + 1):00"
  259. } else {
  260. return selectedDate.formatted(.dateTime.month().day().weekday())
  261. }
  262. }
  263. private func xOffset() -> CGFloat {
  264. let domainDuration = domain.end.timeIntervalSince(domain.start)
  265. guard domainDuration > 0, chartWidth > 0 else { return 0 }
  266. let popoverWidth = popoverSize.width
  267. // Convert dates to pixel'd x-condition
  268. let dateFraction = selectedDate.timeIntervalSince(domain.start) / domainDuration
  269. let x_selected = dateFraction * chartWidth
  270. // TODO: this is semi hacky, can this be improved?
  271. let x_left = x_selected - (popoverWidth / 2) // Left edge of popover
  272. let x_right = x_selected + (popoverWidth / 2) // Right edge of popover
  273. var offset: CGFloat = 0 // Default = no shift
  274. // Push popover to right if its left edge is (nearing) out-of-bounds
  275. if x_left < 0 {
  276. offset = abs(x_left) // push to right
  277. }
  278. // Push popover to left if its right edge is (nearing) out-of-bounds)
  279. if x_right > chartWidth {
  280. offset = -(x_right - chartWidth) // push to left
  281. }
  282. return offset
  283. }
  284. var body: some View {
  285. VStack(alignment: .leading, spacing: 4) {
  286. Text(timeText)
  287. .font(.subheadline)
  288. .bold()
  289. .foregroundStyle(Color.secondary)
  290. Divider()
  291. HStack {
  292. Text(tdd.amount.formatted(.number.precision(.fractionLength(1))))
  293. Text("U").foregroundStyle(Color.secondary)
  294. }
  295. .font(.headline)
  296. }
  297. .padding(20)
  298. .background {
  299. RoundedRectangle(cornerRadius: 10)
  300. .fill(colorScheme == .dark ? Color.bgDarkBlue.opacity(0.9) : Color.white.opacity(0.95))
  301. .shadow(color: Color.secondary, radius: 2)
  302. .overlay(
  303. RoundedRectangle(cornerRadius: 4)
  304. .stroke(Color.blue, lineWidth: 2)
  305. )
  306. }
  307. .frame(minWidth: 100, maxWidth: .infinity) // Ensures proper width
  308. .background(
  309. GeometryReader { geo in
  310. Color.clear
  311. .onAppear { popoverSize = geo.size }
  312. .onChange(of: geo.size) { _, newValue in popoverSize = newValue }
  313. }
  314. )
  315. // Apply calculated xOffset to keep within bounds
  316. .offset(x: xOffset(), y: 0)
  317. }
  318. }