AggregatedStatsView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. // LoopFollow
  2. // AggregatedStatsView.swift
  3. import SwiftUI
  4. import UIKit
  5. struct AggregatedStatsView: View {
  6. @ObservedObject var viewModel: AggregatedStatsViewModel
  7. @Environment(\.dismiss) var dismiss
  8. @State private var showGMI: Bool
  9. @State private var showStdDev: Bool
  10. @State private var startDate: Date
  11. @State private var endDate: Date
  12. @State private var isLoadingData = false
  13. @State private var showLoadingMessage = false
  14. @State private var loadingError = false
  15. @State private var loadingTimer: Timer?
  16. @State private var timeoutTimer: Timer?
  17. init(viewModel: AggregatedStatsViewModel) {
  18. self.viewModel = viewModel
  19. _showGMI = State(initialValue: Storage.shared.showGMI.value)
  20. _showStdDev = State(initialValue: Storage.shared.showStdDev.value)
  21. let calendar = dateTimeUtils.displayCalendar()
  22. let startOfToday = calendar.startOfDay(for: Date())
  23. let end = calendar.date(byAdding: .second, value: -1, to: startOfToday) ?? Date()
  24. let endDay = calendar.startOfDay(for: end)
  25. let startDay = calendar.date(byAdding: .day, value: -7, to: endDay) ?? endDay
  26. let start = calendar.startOfDay(for: startDay)
  27. _startDate = State(initialValue: start)
  28. _endDate = State(initialValue: end)
  29. }
  30. var body: some View {
  31. ScrollView {
  32. VStack(spacing: 20) {
  33. VStack(spacing: 12) {
  34. Text("Statistics")
  35. .font(.largeTitle)
  36. .fontWeight(.bold)
  37. DateRangePicker(
  38. startDate: $startDate,
  39. endDate: $endDate,
  40. availability: viewModel.dataAvailability,
  41. onDateChange: {
  42. loadingError = false
  43. isLoadingData = true
  44. viewModel.updateDateRange(start: startDate, end: endDate) {
  45. isLoadingData = false
  46. }
  47. }
  48. )
  49. .padding(.horizontal)
  50. }
  51. .padding(.top)
  52. if loadingError {
  53. VStack(spacing: 8) {
  54. Text("#WeAreNotWaitingAnymore")
  55. .font(.subheadline)
  56. .fontWeight(.medium)
  57. .foregroundColor(.orange)
  58. Text("...because the data could not be loaded")
  59. .font(.caption)
  60. .foregroundColor(.secondary)
  61. }
  62. .padding()
  63. } else if isLoadingData {
  64. VStack(spacing: 12) {
  65. ProgressView("Loading data...")
  66. if showLoadingMessage {
  67. Text("#WeAreWaitingForData")
  68. .font(.caption)
  69. .foregroundColor(.secondary)
  70. }
  71. }
  72. .padding()
  73. }
  74. StatsGridView(
  75. simpleStats: viewModel.simpleStats,
  76. showGMI: $showGMI,
  77. showStdDev: $showStdDev
  78. )
  79. .padding(.horizontal)
  80. .opacity(isLoadingData ? 0.4 : 1.0)
  81. .disabled(isLoadingData)
  82. AGPView(viewModel: viewModel.agpStats)
  83. .padding(.horizontal)
  84. .opacity(isLoadingData ? 0.4 : 1.0)
  85. TIRView(viewModel: viewModel.tirStats)
  86. .padding(.horizontal)
  87. .opacity(isLoadingData ? 0.4 : 1.0)
  88. GRIView(viewModel: viewModel.griStats)
  89. .padding(.horizontal)
  90. .opacity(isLoadingData ? 0.4 : 1.0)
  91. }
  92. .padding(.bottom)
  93. .frame(maxWidth: .infinity)
  94. }
  95. .navigationBarTitleDisplayMode(.inline)
  96. .toolbar {
  97. ToolbarItem(placement: .navigationBarLeading) {
  98. Button("Refresh") {
  99. loadingError = false
  100. isLoadingData = true
  101. viewModel.updateDateRange(start: startDate, end: endDate) {
  102. isLoadingData = false
  103. }
  104. }
  105. }
  106. }
  107. .onAppear {
  108. loadingError = false
  109. isLoadingData = true
  110. viewModel.updateDateRange(start: startDate, end: endDate) {
  111. isLoadingData = false
  112. }
  113. }
  114. .onChange(of: isLoadingData) { newValue in
  115. if newValue {
  116. showLoadingMessage = false
  117. loadingError = false
  118. // Show "still waiting" message after 3 seconds
  119. loadingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
  120. showLoadingMessage = true
  121. }
  122. // Timeout after 30 seconds
  123. timeoutTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: false) { _ in
  124. if self.isLoadingData {
  125. self.isLoadingData = false
  126. self.loadingError = true
  127. self.viewModel.clearAllStats()
  128. }
  129. }
  130. } else {
  131. loadingTimer?.invalidate()
  132. loadingTimer = nil
  133. timeoutTimer?.invalidate()
  134. timeoutTimer = nil
  135. showLoadingMessage = false
  136. }
  137. }
  138. .onDisappear {
  139. loadingTimer?.invalidate()
  140. loadingTimer = nil
  141. timeoutTimer?.invalidate()
  142. timeoutTimer = nil
  143. }
  144. .onChange(of: showGMI) { newValue in
  145. Storage.shared.showGMI.value = newValue
  146. }
  147. .onChange(of: showStdDev) { newValue in
  148. Storage.shared.showStdDev.value = newValue
  149. }
  150. }
  151. }
  152. struct StatCard: View {
  153. let title: String
  154. let value: String
  155. let unit: String?
  156. let color: Color
  157. var isInteractive: Bool = false
  158. var body: some View {
  159. ZStack(alignment: .topTrailing) {
  160. VStack(alignment: .leading, spacing: 8) {
  161. Text(title)
  162. .font(.caption)
  163. .foregroundColor(.secondary)
  164. HStack(alignment: .firstTextBaseline, spacing: 4) {
  165. Text(value)
  166. .font(.title2)
  167. .fontWeight(.semibold)
  168. .foregroundColor(color)
  169. if let unit = unit {
  170. Text(unit)
  171. .font(.caption)
  172. .foregroundColor(.secondary)
  173. }
  174. }
  175. }
  176. .frame(maxWidth: .infinity, alignment: .leading)
  177. .padding()
  178. if isInteractive {
  179. Image(systemName: "chevron.up.chevron.down")
  180. .font(.caption2)
  181. .foregroundColor(.secondary.opacity(0.5))
  182. .padding(8)
  183. }
  184. }
  185. .background(Color(.systemGray6))
  186. .cornerRadius(12)
  187. }
  188. }
  189. struct InsulinTotalsCard: View {
  190. let totalNegativeBasal: Double?
  191. let totalPositiveBasal: Double?
  192. let programmedBasal: Double?
  193. let actualBasal: Double?
  194. let avgBolus: Double?
  195. let totalDailyDose: Double?
  196. var body: some View {
  197. VStack(alignment: .leading, spacing: 8) {
  198. Text("Insulin Totals")
  199. .font(.caption)
  200. .foregroundColor(.secondary)
  201. VStack(spacing: 10) {
  202. metricRow(title: "Programmed Basal", value: programmedBasal, color: .indigo)
  203. metricRow(title: "Total Negative Basal", value: totalNegativeBasal, color: .red)
  204. metricRow(title: "Total Positive Basal", value: totalPositiveBasal, color: .green)
  205. metricRow(title: "Actual Basal", value: actualBasal, color: .indigo)
  206. metricRow(title: "Avg Bolus", value: avgBolus, color: .purple)
  207. metricRow(title: "Total Daily Dose", value: totalDailyDose, color: .pink)
  208. }
  209. }
  210. .frame(maxWidth: .infinity, alignment: .leading)
  211. .padding()
  212. .background(Color(.systemGray6))
  213. .cornerRadius(12)
  214. }
  215. @ViewBuilder
  216. private func metricRow(title: String, value: Double?, color: Color) -> some View {
  217. HStack(alignment: .firstTextBaseline) {
  218. Text(title)
  219. .font(.subheadline)
  220. .foregroundColor(.secondary)
  221. Spacer()
  222. Text(formatBasal(value))
  223. .font(.headline)
  224. .fontWeight(.semibold)
  225. .foregroundColor(color)
  226. + Text(" U")
  227. .font(.caption)
  228. .foregroundColor(.secondary)
  229. }
  230. }
  231. private func formatBasal(_ value: Double?) -> String {
  232. guard let value = value else { return "---" }
  233. return String(format: "%.2f", value)
  234. }
  235. }
  236. struct StatsGridView: View {
  237. @ObservedObject var simpleStats: SimpleStatsViewModel
  238. @Binding var showGMI: Bool
  239. @Binding var showStdDev: Bool
  240. @State private var showInsulinBreakdown = false
  241. private var hasInsulinData: Bool {
  242. simpleStats.totalDailyDose != nil || simpleStats.avgBolus != nil || simpleStats.actualBasal != nil || simpleStats.programmedBasal != nil
  243. }
  244. private var hasCarbData: Bool {
  245. simpleStats.avgCarbs != nil
  246. }
  247. var body: some View {
  248. VStack(spacing: 16) {
  249. LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
  250. Button(action: {
  251. showGMI.toggle()
  252. }) {
  253. StatCard(
  254. title: showGMI ? "GMI" : "eHbA1c",
  255. value: showGMI ? formatGMI(simpleStats.gmi) : formatEhbA1c(simpleStats.avgGlucose),
  256. unit: showGMI ? "%" : (Storage.shared.units.value == "mg/dL" ? "%" : "mmol/mol"),
  257. color: .blue,
  258. isInteractive: true
  259. )
  260. }
  261. .buttonStyle(PlainButtonStyle())
  262. StatCard(
  263. title: "Avg Glucose",
  264. value: formatGlucose(simpleStats.avgGlucose),
  265. unit: Storage.shared.units.value,
  266. color: Color(red: 0.20, green: 0.99, blue: 0.70)
  267. )
  268. Button(action: {
  269. showStdDev.toggle()
  270. }) {
  271. StatCard(
  272. title: showStdDev ? "Std Deviation" : "CV",
  273. value: showStdDev ? formatStdDev(simpleStats.stdDeviation) : formatCV(simpleStats.coefficientOfVariation),
  274. unit: showStdDev ? Storage.shared.units.value : "%",
  275. color: .orange,
  276. isInteractive: true
  277. )
  278. }
  279. .buttonStyle(PlainButtonStyle())
  280. if hasCarbData {
  281. StatCard(
  282. title: "Avg Carbs",
  283. value: formatCarbs(simpleStats.avgCarbs),
  284. unit: "g/day",
  285. color: .yellow
  286. )
  287. }
  288. if hasInsulinData {
  289. StatCard(
  290. title: "Programmed Basal",
  291. value: formatInsulin(simpleStats.programmedBasal),
  292. unit: "U/day",
  293. color: .indigo
  294. )
  295. .onTapGesture(count: 3) {
  296. showInsulinBreakdown = true
  297. }
  298. StatCard(
  299. title: "Avg Bolus",
  300. value: formatInsulin(simpleStats.avgBolus),
  301. unit: "U/day",
  302. color: .purple
  303. )
  304. .onTapGesture(count: 3) {
  305. showInsulinBreakdown = true
  306. }
  307. StatCard(
  308. title: "Total Daily Dose",
  309. value: formatInsulin(simpleStats.totalDailyDose),
  310. unit: "U",
  311. color: .pink
  312. )
  313. .onTapGesture(count: 3) {
  314. showInsulinBreakdown = true
  315. }
  316. }
  317. }
  318. if hasInsulinData && showInsulinBreakdown {
  319. InsulinTotalsCard(
  320. totalNegativeBasal: simpleStats.totalNegativeBasal,
  321. totalPositiveBasal: simpleStats.totalPositiveBasal,
  322. programmedBasal: simpleStats.programmedBasal,
  323. actualBasal: simpleStats.actualBasal,
  324. avgBolus: simpleStats.avgBolus,
  325. totalDailyDose: simpleStats.totalDailyDose
  326. )
  327. }
  328. }
  329. }
  330. private func formatGMI(_ value: Double?) -> String {
  331. guard let value = value else { return "---" }
  332. return String(format: "%.1f", value)
  333. }
  334. private func formatEhbA1c(_ avgGlucose: Double?) -> String {
  335. guard let avgGlucose = avgGlucose else { return "---" }
  336. let avgGlucoseMgdL: Double
  337. if Storage.shared.units.value == "mg/dL" {
  338. avgGlucoseMgdL = avgGlucose
  339. } else {
  340. avgGlucoseMgdL = avgGlucose * 18.0182
  341. }
  342. let ehba1cPercent = (avgGlucoseMgdL + 46.7) / 28.7
  343. if Storage.shared.units.value == "mg/dL" {
  344. return String(format: "%.1f", ehba1cPercent)
  345. } else {
  346. let ehba1cMmolMol = (ehba1cPercent - 2.15) * 10.929
  347. return String(format: "%.0f", ehba1cMmolMol)
  348. }
  349. }
  350. private func formatGlucose(_ value: Double?) -> String {
  351. guard let value = value else { return "---" }
  352. if Storage.shared.units.value == "mg/dL" {
  353. return String(format: "%.0f", value)
  354. } else {
  355. return String(format: "%.1f", value)
  356. }
  357. }
  358. private func formatStdDev(_ value: Double?) -> String {
  359. guard let value = value else { return "---" }
  360. if Storage.shared.units.value == "mg/dL" {
  361. return String(format: "%.0f", value)
  362. } else {
  363. return String(format: "%.1f", value)
  364. }
  365. }
  366. private func formatInsulin(_ value: Double?) -> String {
  367. guard let value = value else { return "---" }
  368. return String(format: "%.2f", value)
  369. }
  370. private func formatCarbs(_ value: Double?) -> String {
  371. guard let value = value else { return "---" }
  372. return String(format: "%.0f", value)
  373. }
  374. private func formatCV(_ value: Double?) -> String {
  375. guard let value = value else { return "---" }
  376. return String(format: "%.1f", value)
  377. }
  378. }