TDDChart.swift 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import Charts
  2. import SwiftUI
  3. struct TDDChartView: View {
  4. let selectedDuration: Stat.StateModel.StatsTimeInterval
  5. let tddStats: [TDD]
  6. let calculateAverage: (Date, Date) -> Decimal
  7. @State private var scrollPosition = Date()
  8. @State private var currentAverageTDD: Decimal = 0
  9. @State private var selectedDate: Date?
  10. private var visibleDomainLength: TimeInterval {
  11. switch selectedDuration {
  12. case .Day: return 3 * 24 * 3600 // 3 days
  13. case .Week: return 7 * 24 * 3600 // 1 week
  14. case .Month: return 30 * 24 * 3600 // 1 month
  15. case .Total: return 90 * 24 * 3600 // 3 months
  16. }
  17. }
  18. private var scrollTargetDuration: TimeInterval {
  19. switch selectedDuration {
  20. case .Day: return 3 * 24 * 3600 // Scroll by 3 days
  21. case .Week: return 7 * 24 * 3600 // Scroll by 1 week
  22. case .Month: return 30 * 24 * 3600 // Scroll by 1 month
  23. case .Total: return 90 * 24 * 3600 // Scroll by 3 months
  24. }
  25. }
  26. private var strideInterval: Calendar.Component {
  27. .day
  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)
  45. case .Week:
  46. return DateComponents(weekday: 1)
  47. case .Month:
  48. return DateComponents(day: 1)
  49. case .Total:
  50. return DateComponents(day: 1, hour: 0)
  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 updateAverage() {
  60. let (start, end) = visibleDateRange
  61. currentAverageTDD = calculateAverage(start, end)
  62. }
  63. private func getTDDForDate(_ date: Date) -> TDD? {
  64. tddStats.first { tdd in
  65. guard let timestamp = tdd.timestamp else { return false }
  66. return Calendar.current.isDate(timestamp, inSameDayAs: date)
  67. }
  68. }
  69. var body: some View {
  70. chartCard
  71. .onChange(of: scrollPosition) {
  72. updateAverage()
  73. }
  74. .onAppear {
  75. updateAverage()
  76. }
  77. }
  78. // MARK: - Views
  79. private var chartCard: some View {
  80. VStack(alignment: .leading, spacing: 8) {
  81. VStack(alignment: .leading, spacing: 6) {
  82. Text("Total Daily Doses")
  83. .font(.headline)
  84. VStack(alignment: .leading, spacing: 4) {
  85. Text("Average: \(currentAverageTDD.formatted(.number.precision(.fractionLength(1)))) U")
  86. .font(.headline)
  87. .foregroundStyle(.secondary)
  88. Text(
  89. "\(visibleDateRange.start.formatted(.dateTime.month().day())) - \(visibleDateRange.end.formatted(.dateTime.month().day()))"
  90. )
  91. .font(.subheadline)
  92. .foregroundStyle(.secondary)
  93. }
  94. }
  95. Chart {
  96. ForEach(tddStats) { entry in
  97. BarMark(
  98. x: .value("Date", entry.timestamp ?? Date(), unit: strideInterval),
  99. y: .value("Insulin", entry.totalDailyDose ?? 0)
  100. )
  101. .foregroundStyle(Color.insulin.gradient)
  102. }
  103. if let selectedDate,
  104. let selectedTDD = getTDDForDate(selectedDate)
  105. {
  106. RuleMark(
  107. x: .value("Selected Date", selectedDate)
  108. )
  109. .foregroundStyle(.secondary.opacity(0.3))
  110. .annotation(
  111. position: .top,
  112. spacing: 0,
  113. overflowResolution: .init(x: .fit, y: .disabled)
  114. ) {
  115. TDDSelectionPopover(date: selectedDate, tdd: selectedTDD)
  116. }
  117. }
  118. }
  119. .chartYAxis {
  120. AxisMarks { _ in
  121. AxisValueLabel()
  122. AxisGridLine()
  123. }
  124. }
  125. .chartXAxis {
  126. AxisMarks(preset: .aligned, values: .stride(by: strideInterval)) { value in
  127. if let date = value.as(Date.self) {
  128. let day = Calendar.current.component(.day, from: date)
  129. switch selectedDuration {
  130. case .Month:
  131. if day % 5 == 0 { // Only show every 5th day
  132. AxisValueLabel(format: dateFormat)
  133. AxisGridLine()
  134. }
  135. case .Total:
  136. // Only show January, April, July, October
  137. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  138. AxisValueLabel(format: dateFormat)
  139. AxisGridLine()
  140. }
  141. default:
  142. AxisValueLabel(format: dateFormat)
  143. AxisGridLine()
  144. }
  145. }
  146. }
  147. }
  148. .chartXSelection(value: $selectedDate)
  149. .chartScrollableAxes(.horizontal)
  150. .chartScrollPosition(x: $scrollPosition)
  151. .chartScrollTargetBehavior(
  152. .valueAligned(
  153. matching: alignmentComponents,
  154. majorAlignment: .matching(alignmentComponents)
  155. )
  156. )
  157. .chartXVisibleDomain(length: visibleDomainLength)
  158. .frame(height: 200)
  159. }
  160. }
  161. private struct TDDSelectionPopover: View {
  162. let date: Date
  163. let tdd: TDD
  164. var body: some View {
  165. VStack(alignment: .center, spacing: 4) {
  166. Text(date.formatted(.dateTime.month().day()))
  167. .font(.caption)
  168. .foregroundStyle(.secondary)
  169. Text("\(tdd.totalDailyDose?.formatted(.number.precision(.fractionLength(1))) ?? "0") U")
  170. .font(.callout.bold())
  171. }
  172. .padding(8)
  173. .background(
  174. RoundedRectangle(cornerRadius: 8)
  175. .fill(Color(.systemBackground))
  176. .shadow(radius: 2)
  177. )
  178. }
  179. }
  180. }