TDDChart.swift 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import Charts
  2. import SwiftUI
  3. struct TDDChartView: View {
  4. @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
  5. let tddStats: [TDD]
  6. let calculateAverage: @Sendable(Date, Date) async -> Decimal
  7. let calculateMedian: @Sendable(Date, Date) async -> Decimal
  8. @State private var scrollPosition = Date()
  9. @State private var currentAverageTDD: Decimal = 0
  10. @State private var currentMedianTDD: Decimal = 0
  11. @State private var selectedDate: Date?
  12. @State private var isScrolling = false
  13. @State private var updateTimer = Stat.UpdateTimer()
  14. private var visibleDomainLength: TimeInterval {
  15. switch selectedDuration {
  16. case .Day: return 3 * 24 * 3600 // 3 days
  17. case .Week: return 7 * 24 * 3600 // 1 week
  18. case .Month: return 30 * 24 * 3600 // 1 month
  19. case .Total: return 90 * 24 * 3600 // 3 months
  20. }
  21. }
  22. private var scrollTargetDuration: TimeInterval {
  23. switch selectedDuration {
  24. case .Day: return 3 * 24 * 3600 // Scroll by 3 days
  25. case .Week: return 7 * 24 * 3600 // Scroll by 1 week
  26. case .Month: return 30 * 24 * 3600 // Scroll by 1 month
  27. case .Total: return 90 * 24 * 3600 // Scroll by 3 months
  28. }
  29. }
  30. private var dateFormat: Date.FormatStyle {
  31. switch selectedDuration {
  32. case .Day:
  33. return .dateTime.hour()
  34. case .Week:
  35. return .dateTime.weekday(.abbreviated)
  36. case .Month:
  37. return .dateTime.day()
  38. case .Total:
  39. return .dateTime.month(.abbreviated)
  40. }
  41. }
  42. private var alignmentComponents: DateComponents {
  43. switch selectedDuration {
  44. case .Day:
  45. return DateComponents(hour: 0) // Align to start of day
  46. case .Week:
  47. return DateComponents(weekday: 2) // 2 = Monday in Calendar
  48. case .Month,
  49. .Total:
  50. return DateComponents(day: 1) // Align to first day of month
  51. }
  52. }
  53. private var visibleDateRange: (start: Date, end: Date) {
  54. let halfDomain = visibleDomainLength / 2
  55. let start = scrollPosition.addingTimeInterval(-halfDomain)
  56. let end = scrollPosition.addingTimeInterval(halfDomain)
  57. return (start, end)
  58. }
  59. private func updateStats() {
  60. Task.detached(priority: .userInitiated) {
  61. let dateRange = await MainActor.run { visibleDateRange }
  62. let avgTDD = await calculateAverage(dateRange.start, dateRange.end)
  63. let medTDD = await calculateMedian(dateRange.start, dateRange.end)
  64. await MainActor.run {
  65. currentAverageTDD = avgTDD
  66. currentMedianTDD = medTDD
  67. }
  68. }
  69. }
  70. private func getTDDForDate(_ date: Date) -> TDD? {
  71. tddStats.first { tdd in
  72. guard let timestamp = tdd.timestamp else { return false }
  73. return Calendar.current.isDate(timestamp, inSameDayAs: date)
  74. }
  75. }
  76. var body: some View {
  77. chartCard
  78. .onAppear {
  79. updateStats()
  80. }
  81. .onChange(of: scrollPosition) {
  82. isScrolling = true
  83. updateTimer.scheduleUpdate {
  84. updateStats()
  85. isScrolling = false
  86. }
  87. }
  88. .onChange(of: selectedDuration) {
  89. updateStats()
  90. scrollPosition = Date()
  91. }
  92. }
  93. // MARK: - Views
  94. private var chartCard: some View {
  95. VStack(alignment: .leading, spacing: 8) {
  96. statsView
  97. Chart {
  98. ForEach(tddStats) { entry in
  99. BarMark(
  100. x: .value("Date", entry.timestamp ?? Date(), unit: selectedDuration == .Day ? .hour : .day),
  101. y: .value("TDD", entry.totalDailyDose ?? 0)
  102. )
  103. .foregroundStyle(Color.insulin.gradient)
  104. }
  105. if let selectedDate,
  106. let selectedTDD = getTDDForDate(selectedDate)
  107. {
  108. RuleMark(
  109. x: .value("Selected Date", selectedDate)
  110. )
  111. .foregroundStyle(.secondary.opacity(0.3))
  112. .annotation(
  113. position: .top,
  114. spacing: 0,
  115. overflowResolution: .init(x: .fit, y: .disabled)
  116. ) {
  117. TDDSelectionPopover(date: selectedDate, tdd: selectedTDD)
  118. }
  119. }
  120. }
  121. .chartYAxis {
  122. AxisMarks { _ in
  123. AxisValueLabel()
  124. AxisGridLine()
  125. }
  126. }
  127. .chartXAxis {
  128. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  129. if let date = value.as(Date.self) {
  130. let day = Calendar.current.component(.day, from: date)
  131. let hour = Calendar.current.component(.hour, from: date)
  132. switch selectedDuration {
  133. case .Day:
  134. if hour % 6 == 0 { // Show only every 6 hours
  135. AxisValueLabel(format: dateFormat, centered: true)
  136. AxisGridLine()
  137. }
  138. case .Month:
  139. if day % 5 == 0 { // Only show every 5th day
  140. AxisValueLabel(format: dateFormat, centered: true)
  141. AxisGridLine()
  142. }
  143. case .Total:
  144. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  145. AxisValueLabel(format: dateFormat, centered: true)
  146. AxisGridLine()
  147. }
  148. default:
  149. AxisValueLabel(format: dateFormat, centered: true)
  150. AxisGridLine()
  151. }
  152. }
  153. }
  154. }
  155. .chartXSelection(value: $selectedDate)
  156. .chartScrollableAxes(.horizontal)
  157. .chartScrollPosition(x: $scrollPosition)
  158. .chartScrollTargetBehavior(
  159. .valueAligned(
  160. matching: selectedDuration == .Day ?
  161. DateComponents(minute: 0) : // Align to next hour for Day view
  162. DateComponents(hour: 0), // Align to start of day for other views
  163. majorAlignment: .matching(alignmentComponents)
  164. )
  165. )
  166. .chartXVisibleDomain(length: visibleDomainLength)
  167. .frame(height: 200)
  168. }
  169. }
  170. private var statsView: some View {
  171. HStack {
  172. Grid(alignment: .leading) {
  173. GridRow {
  174. Text("Average:")
  175. .font(.headline)
  176. .foregroundStyle(.secondary)
  177. Text(currentAverageTDD.formatted(.number.precision(.fractionLength(1))))
  178. .font(.headline)
  179. .foregroundStyle(.secondary)
  180. .gridColumnAlignment(.trailing)
  181. Text("U")
  182. .font(.headline)
  183. .foregroundStyle(.secondary)
  184. }
  185. GridRow {
  186. Text("Median:")
  187. .font(.headline)
  188. .foregroundStyle(.secondary)
  189. Text(currentMedianTDD.formatted(.number.precision(.fractionLength(1))))
  190. .font(.headline)
  191. .foregroundStyle(.secondary)
  192. .gridColumnAlignment(.trailing)
  193. Text("U")
  194. .font(.headline)
  195. .foregroundStyle(.secondary)
  196. }
  197. }
  198. Spacer()
  199. Text(formatVisibleDateRange(showTimeRange: isScrolling))
  200. .font(.subheadline)
  201. .foregroundStyle(.secondary)
  202. }
  203. }
  204. private func formatVisibleDateRange(showTimeRange: Bool = false) -> String {
  205. let start = visibleDateRange.start
  206. let end = visibleDateRange.end
  207. let calendar = Calendar.current
  208. switch selectedDuration {
  209. case .Day:
  210. let today = Date()
  211. let isToday = calendar.isDate(start, inSameDayAs: today)
  212. let isYesterday = calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!)
  213. if isToday || isYesterday, !showTimeRange {
  214. return isToday ? "Today" : "Yesterday"
  215. }
  216. let timeRange =
  217. "\(start.formatted(.dateTime.hour(.twoDigits(amPM: .wide)))) - \(end.formatted(.dateTime.hour(.twoDigits(amPM: .wide))))"
  218. if isToday {
  219. return "Today, \(timeRange)"
  220. } else if isYesterday {
  221. return "Yesterday, \(timeRange)"
  222. } else {
  223. return "\(start.formatted(.dateTime.month().day())), \(timeRange)"
  224. }
  225. default:
  226. return "\(start.formatted(.dateTime.month().day())) - \(end.formatted(.dateTime.month().day()))"
  227. }
  228. }
  229. private struct TDDSelectionPopover: View {
  230. let date: Date
  231. let tdd: TDD
  232. var body: some View {
  233. VStack(alignment: .center, spacing: 4) {
  234. Text(date.formatted(.dateTime.month().day()))
  235. .font(.caption)
  236. .foregroundStyle(.secondary)
  237. Text("\(tdd.totalDailyDose?.formatted(.number.precision(.fractionLength(1))) ?? "0") U")
  238. .font(.callout.bold())
  239. }
  240. .padding(8)
  241. .background(
  242. RoundedRectangle(cornerRadius: 8)
  243. .fill(Color(.systemBackground))
  244. .shadow(radius: 2)
  245. )
  246. }
  247. }
  248. }