TDDChart.swift 9.1 KB

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