TDDChart.swift 8.2 KB

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