TDDChart.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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. let calendar = Calendar.current
  49. return tddStats.first { stat in
  50. switch selectedDuration {
  51. case .Day:
  52. return calendar.isDate(stat.date, equalTo: date, toGranularity: .hour)
  53. default:
  54. return calendar.isDate(stat.date, inSameDayAs: date)
  55. }
  56. }
  57. }
  58. private func updateAverages() {
  59. currentAverage = state.getCachedTDDAverages(for: visibleDateRange)
  60. }
  61. /// Formats the visible date range into a human-readable string
  62. private func formatVisibleDateRange() -> String {
  63. let start = visibleDateRange.start
  64. let end = visibleDateRange.end
  65. let calendar = Calendar.current
  66. let today = Date()
  67. // Special handling for Day view with relative dates
  68. if selectedDuration == .Day {
  69. let startDateText: String
  70. let endDateText: String
  71. let timeFormat = start.formatted(.dateTime.hour().minute())
  72. // Format start date
  73. if calendar.isDate(start, inSameDayAs: today) {
  74. startDateText = "Today"
  75. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  76. startDateText = "Yesterday"
  77. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  78. startDateText = "Tomorrow"
  79. } else {
  80. startDateText = start.formatted(.dateTime.day().month())
  81. }
  82. // Format end date
  83. if calendar.isDate(end, inSameDayAs: today) {
  84. endDateText = "Today"
  85. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  86. endDateText = "Yesterday"
  87. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  88. endDateText = "Tomorrow"
  89. } else {
  90. endDateText = end.formatted(.dateTime.day().month())
  91. }
  92. // If start and end are on the same day, show date only once
  93. if calendar.isDate(start, inSameDayAs: end) {
  94. return "\(startDateText), \(timeFormat) - \(end.formatted(.dateTime.hour().minute()))"
  95. }
  96. return "\(startDateText), \(timeFormat) - \(endDateText), \(end.formatted(.dateTime.hour().minute()))"
  97. }
  98. // Standard format for other views - only show dates without time
  99. let startText: String
  100. let endText: String
  101. // Check for relative dates
  102. if calendar.isDate(start, inSameDayAs: today) {
  103. startText = "Today"
  104. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  105. startText = "Yesterday"
  106. } else if calendar.isDate(start, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  107. startText = "Tomorrow"
  108. } else {
  109. startText = start.formatted(.dateTime.day().month())
  110. }
  111. if calendar.isDate(end, inSameDayAs: today) {
  112. endText = "Today"
  113. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) {
  114. endText = "Yesterday"
  115. } else if calendar.isDate(end, inSameDayAs: calendar.date(byAdding: .day, value: 1, to: today)!) {
  116. endText = "Tomorrow"
  117. } else {
  118. endText = end.formatted(.dateTime.day().month())
  119. }
  120. return "\(startText) - \(endText)"
  121. }
  122. private func getInitialScrollPosition() -> Date {
  123. let calendar = Calendar.current
  124. let now = Date()
  125. switch selectedDuration {
  126. case .Day:
  127. return calendar.date(byAdding: .day, value: -1, to: now)!
  128. case .Week:
  129. return calendar.date(byAdding: .day, value: -7, to: now)!
  130. case .Month:
  131. return calendar.date(byAdding: .month, value: -1, to: now)!
  132. case .Total:
  133. return calendar.date(byAdding: .month, value: -3, to: now)!
  134. }
  135. }
  136. private func isSameTimeUnit(_ date1: Date, _ date2: Date) -> Bool {
  137. switch selectedDuration {
  138. case .Day:
  139. return Calendar.current.isDate(date1, equalTo: date2, toGranularity: .hour)
  140. default:
  141. return Calendar.current.isDate(date1, inSameDayAs: date2)
  142. }
  143. }
  144. var body: some View {
  145. VStack(alignment: .leading, spacing: 8) {
  146. statsView
  147. chartsView
  148. }
  149. .onAppear {
  150. scrollPosition = getInitialScrollPosition()
  151. updateAverages()
  152. }
  153. .onChange(of: scrollPosition) {
  154. updateTimer.scheduleUpdate {
  155. updateAverages()
  156. }
  157. }
  158. .onChange(of: selectedDuration) {
  159. Task {
  160. scrollPosition = getInitialScrollPosition()
  161. updateAverages()
  162. }
  163. }
  164. }
  165. private var statsView: some View {
  166. HStack {
  167. Text("Average:")
  168. .font(.headline)
  169. .foregroundStyle(.secondary)
  170. Text(currentAverage.formatted(.number.precision(.fractionLength(1))))
  171. .font(.headline)
  172. .foregroundStyle(.secondary)
  173. Text("U")
  174. .font(.headline)
  175. .foregroundStyle(.secondary)
  176. Spacer()
  177. Text(formatVisibleDateRange())
  178. .font(.footnote)
  179. .foregroundStyle(.secondary)
  180. }
  181. }
  182. private var chartsView: some View {
  183. Chart {
  184. ForEach(tddStats) { stat in
  185. BarMark(
  186. x: .value("Date", stat.date, unit: selectedDuration == .Day ? .hour : .day),
  187. y: .value("Amount", stat.amount)
  188. )
  189. .foregroundStyle(Color.insulin)
  190. .opacity(
  191. selectedDate.map { date in
  192. isSameTimeUnit(stat.date, date) ? 1 : 0.3
  193. } ?? 1
  194. )
  195. }
  196. // Selection popover outside of the ForEach loop!
  197. if let selectedDate,
  198. let selectedTDD = getTDDForDate(selectedDate)
  199. {
  200. RuleMark(
  201. x: .value("Selected Date", selectedDate)
  202. )
  203. .foregroundStyle(.secondary.opacity(0.5))
  204. .annotation(
  205. position: .top,
  206. spacing: 0,
  207. overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
  208. ) {
  209. TDDSelectionPopover(date: selectedDate, tdd: selectedTDD, selectedDuration: selectedDuration)
  210. }
  211. }
  212. }
  213. .chartYAxis {
  214. AxisMarks(position: .trailing) { value in
  215. if let amount = value.as(Double.self) {
  216. AxisValueLabel {
  217. Text(amount.formatted(.number.precision(.fractionLength(0))) + " U")
  218. .font(.footnote)
  219. }
  220. AxisGridLine()
  221. }
  222. }
  223. }
  224. .chartXAxis {
  225. AxisMarks(preset: .aligned, values: .stride(by: selectedDuration == .Day ? .hour : .day)) { value in
  226. if let date = value.as(Date.self) {
  227. let day = Calendar.current.component(.day, from: date)
  228. let hour = Calendar.current.component(.hour, from: date)
  229. switch selectedDuration {
  230. case .Day:
  231. if hour % 6 == 0 {
  232. AxisValueLabel(format: dateFormat, centered: true)
  233. .font(.footnote)
  234. AxisGridLine()
  235. }
  236. case .Month:
  237. if day % 5 == 0 {
  238. AxisValueLabel(format: dateFormat, centered: true)
  239. .font(.footnote)
  240. AxisGridLine()
  241. }
  242. case .Total:
  243. if day == 1 && Calendar.current.component(.month, from: date) % 3 == 1 {
  244. AxisValueLabel(format: dateFormat, centered: true)
  245. .font(.footnote)
  246. AxisGridLine()
  247. }
  248. default:
  249. AxisValueLabel(format: dateFormat, centered: true)
  250. .font(.footnote)
  251. AxisGridLine()
  252. }
  253. }
  254. }
  255. }
  256. .chartScrollableAxes(.horizontal)
  257. .chartXSelection(value: $selectedDate.animation(.easeInOut))
  258. .chartScrollPosition(x: $scrollPosition)
  259. .chartScrollTargetBehavior(
  260. .valueAligned(
  261. matching: selectedDuration == .Day ?
  262. DateComponents(minute: 0) :
  263. DateComponents(hour: 0),
  264. majorAlignment: .matching(alignmentComponents)
  265. )
  266. )
  267. .chartXVisibleDomain(length: visibleDomainLength)
  268. .frame(height: 250)
  269. }
  270. }
  271. private struct TDDSelectionPopover: View {
  272. let date: Date
  273. let tdd: TDDStats
  274. let selectedDuration: Stat.StateModel.StatsTimeInterval
  275. private var timeText: String {
  276. if selectedDuration == .Day {
  277. let hour = Calendar.current.component(.hour, from: date)
  278. return "\(hour):00-\(hour + 1):00"
  279. } else {
  280. return date.formatted(.dateTime.month().day())
  281. }
  282. }
  283. var body: some View {
  284. VStack(alignment: .leading, spacing: 4) {
  285. Text(timeText)
  286. .font(.subheadline)
  287. .fontWeight(.bold)
  288. Text(tdd.amount.formatted(.number.precision(.fractionLength(1))) + " U")
  289. .font(.title3.bold())
  290. }
  291. .foregroundStyle(.white)
  292. .padding(20)
  293. .background {
  294. RoundedRectangle(cornerRadius: 10)
  295. .fill(Color.insulin.gradient)
  296. }
  297. }
  298. }