TDDChart.swift 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import Charts
  2. import SwiftUI
  3. struct TDDChartView: View {
  4. private enum Constants {
  5. static let dayOptions = [3, 5, 7, 10, 14, 21, 28]
  6. static let chartHeight: CGFloat = 200
  7. static let spacing: CGFloat = 8
  8. static let cornerRadius: CGFloat = 10
  9. static let summaryBackgroundOpacity = 0.1
  10. }
  11. let state: Stat.StateModel
  12. @Binding var selectedDays: Int
  13. @Binding var selectedEndDate: Date
  14. @Binding var dailyTotalDoses: [TDD]
  15. var averageTDD: Decimal
  16. var ytdTDD: Decimal
  17. @Environment(\.colorScheme) var colorScheme
  18. var body: some View {
  19. if dailyTotalDoses.isEmpty || state.currentTDD == 0 {
  20. ContentUnavailableView(
  21. "No TDD Data",
  22. systemImage: "chart.bar.xaxis",
  23. description: Text("Total Daily Doses will appear here once data is available.")
  24. )
  25. } else {
  26. VStack(spacing: Constants.spacing) {
  27. dateSelectionView
  28. summaryCardView
  29. chartCard
  30. }
  31. .padding()
  32. }
  33. }
  34. // MARK: - Views
  35. private var dateSelectionView: some View {
  36. HStack {
  37. Text("Time Frame")
  38. .font(.subheadline)
  39. .foregroundStyle(.secondary)
  40. Spacer()
  41. CustomDatePicker(selection: $selectedEndDate)
  42. .frame(height: 30)
  43. Picker("Days", selection: $selectedDays) {
  44. ForEach(Constants.dayOptions, id: \.self) { days in
  45. Text("\(days) days").tag(days)
  46. }
  47. }
  48. .pickerStyle(MenuPickerStyle())
  49. }
  50. }
  51. private var summaryCardView: some View {
  52. VStack(spacing: Constants.spacing) {
  53. tddRow(
  54. title: "Today",
  55. value: state.currentTDD
  56. )
  57. Divider()
  58. tddRow(
  59. title: "Yesterday",
  60. value: ytdTDD
  61. )
  62. Divider()
  63. tddRow(
  64. title: "Average \(selectedDays) days",
  65. value: averageTDD
  66. )
  67. }
  68. .padding()
  69. .background(
  70. RoundedRectangle(cornerRadius: Constants.cornerRadius)
  71. .fill(Color.secondary.opacity(Constants.summaryBackgroundOpacity))
  72. )
  73. }
  74. private var chartCard: some View {
  75. VStack(alignment: .leading, spacing: Constants.spacing) {
  76. Text("Total Daily Doses")
  77. .font(.headline)
  78. Chart {
  79. ForEach(chartData, id: \.date) { entry in
  80. BarMark(
  81. x: .value("Date", entry.date, unit: .day),
  82. y: .value("Insulin", entry.dose)
  83. )
  84. .foregroundStyle(Color.insulin.gradient)
  85. .annotation(position: .top) {
  86. if entry.dose > 0 {
  87. Text(formatDose(entry.dose))
  88. .font(.caption2)
  89. .foregroundStyle(.primary)
  90. }
  91. }
  92. }
  93. if let average = calculateAverage() {
  94. RuleMark(y: .value("Average", average))
  95. .foregroundStyle(.primary)
  96. .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
  97. .annotation(position: .automatic) {
  98. Text("\(formatDose(average)) U")
  99. .font(.caption)
  100. .foregroundStyle(Color.insulin)
  101. }
  102. }
  103. }
  104. .chartXAxis {
  105. tddChartXAxisMarks
  106. }
  107. .chartYAxis {
  108. AxisMarks { _ in
  109. AxisValueLabel()
  110. AxisGridLine()
  111. }
  112. }
  113. .chartYAxisLabel(alignment: .trailing) {
  114. Text("Units (U)")
  115. .foregroundColor(.primary)
  116. }
  117. .chartYScale(domain: 0 ... calculateYAxisMaximum())
  118. }
  119. .frame(height: 200)
  120. .padding()
  121. .background(
  122. RoundedRectangle(cornerRadius: Constants.cornerRadius)
  123. .fill(Color.secondary.opacity(Constants.summaryBackgroundOpacity))
  124. )
  125. }
  126. // MARK: - Helper Views
  127. private var tddChartXAxisMarks: some AxisContent {
  128. AxisMarks(values: .stride(by: .day)) { value in
  129. if let date = value.as(Date.self),
  130. xAxisLabelValues().contains(where: { $0.date == date })
  131. {
  132. AxisValueLabel(xAxisLabelValues().first { $0.date == date }?.label ?? "")
  133. }
  134. AxisGridLine()
  135. }
  136. }
  137. private func tddRow(title: String, value: Decimal) -> some View {
  138. HStack {
  139. Text(title)
  140. .foregroundStyle(.secondary)
  141. Spacer()
  142. Text(formatDose(value))
  143. .foregroundColor(.primary)
  144. Text("U")
  145. .foregroundStyle(.secondary)
  146. }
  147. .font(.subheadline)
  148. }
  149. // MARK: - Data Processing
  150. private var chartData: [(date: Date, dose: Decimal)] {
  151. completeData(forDays: selectedDays)
  152. }
  153. private func calculateAverage() -> Decimal? {
  154. let nonZeroDoses = chartData.map(\.dose).filter { $0 > 0 }
  155. guard !nonZeroDoses.isEmpty else { return nil }
  156. return nonZeroDoses.reduce(0, +) / Decimal(nonZeroDoses.count)
  157. }
  158. private func calculateYAxisMaximum() -> Double {
  159. let maxDose = chartData.map(\.dose).max() ?? 0
  160. let average = calculateAverage() ?? 0
  161. return (max(maxDose, average) * 1.2).doubleValue // Add 20% padding
  162. }
  163. private func formatDose(_ value: Decimal) -> String {
  164. Formatter.decimalFormatterWithOneFractionDigit.string(from: value as NSNumber) ?? "0"
  165. }
  166. private func completeData(forDays days: Int) -> [(date: Date, dose: Decimal)] {
  167. var completeData: [(date: Date, dose: Decimal)] = []
  168. let calendar = Calendar.current
  169. var currentDate = calendar.startOfDay(for: selectedEndDate)
  170. for _ in 0 ..< days {
  171. if let existingEntry = dailyTotalDoses.first(where: { entry in
  172. guard let timestamp = entry.timestamp else { return false }
  173. return calendar.isDate(timestamp, inSameDayAs: currentDate)
  174. }) {
  175. completeData.append((date: currentDate, dose: existingEntry.totalDailyDose ?? 0))
  176. } else {
  177. completeData.append((date: currentDate, dose: 0))
  178. }
  179. currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate) ?? currentDate
  180. }
  181. return completeData.reversed()
  182. }
  183. private func xAxisLabelValues() -> [(date: Date, label: String)] {
  184. let data = chartData
  185. let stride = selectedDays > 13 ? max(1, selectedDays / 7) : 1
  186. return data.enumerated().compactMap { index, entry in
  187. if index % stride == 0 || index == data.count - 1 {
  188. return (date: entry.date, label: Formatter.dayFormatter.string(from: entry.date))
  189. }
  190. return nil
  191. }
  192. }
  193. }
  194. private extension Decimal {
  195. var doubleValue: Double {
  196. NSDecimalNumber(decimal: self).doubleValue
  197. }
  198. }