TDDChart.swift 6.9 KB

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