|
|
@@ -8,6 +8,7 @@ struct GlucoseSectorChart: View {
|
|
|
let units: GlucoseUnits
|
|
|
let glucose: [GlucoseStored]
|
|
|
let timeInRangeType: TimeInRangeType
|
|
|
+ let showChart: Bool
|
|
|
|
|
|
@State private var selectedCount: Int?
|
|
|
@State private var selectedRange: GlucoseRange?
|
|
|
@@ -23,119 +24,181 @@ struct GlucoseSectorChart: View {
|
|
|
}
|
|
|
|
|
|
var body: some View {
|
|
|
- HStack(alignment: .center, spacing: 20) {
|
|
|
- // Calculate total number of glucose readings
|
|
|
- let total = Decimal(glucose.count)
|
|
|
- // Count readings greater than high limit (180 mg/dL)
|
|
|
- let high = glucose.filter { $0.glucose > Int(highLimit) }.count
|
|
|
- // Count readings between low limit (TITR: 70 mg/dL, TING 63 mg/dL) and 140 mg/dL (tight control)
|
|
|
- let tight = glucose
|
|
|
- .filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= timeInRangeType.topThreshold }.count
|
|
|
- // Count readings between 140 and high limit (normal range)
|
|
|
- let normal = glucose.filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= Int(highLimit) }.count
|
|
|
- // Count readings less than low limit (low)
|
|
|
- let low = glucose.filter { $0.glucose < timeInRangeType.bottomThreshold }.count
|
|
|
-
|
|
|
- let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
|
|
|
- let sumReadings = justGlucoseArray.reduce(0, +)
|
|
|
-
|
|
|
- let glucoseAverage = Decimal(sumReadings) / total
|
|
|
- let medianGlucose = StatChartUtils.medianCalculation(array: justGlucoseArray)
|
|
|
-
|
|
|
- let lowPercentage = Decimal(low) / total * 100
|
|
|
- let tightPercentage = Decimal(tight) / total * 100
|
|
|
- let inRangePercentage = Decimal(normal) / total * 100
|
|
|
- let highPercentage = Decimal(high) / total * 100
|
|
|
-
|
|
|
- VStack(alignment: .leading, spacing: 10) {
|
|
|
- VStack(alignment: .leading, spacing: 5) {
|
|
|
- Text("\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(highLimit))").font(.subheadline)
|
|
|
+ if glucose.count < 1 {
|
|
|
+ Text("No glucose readings found.")
|
|
|
+ } else {
|
|
|
+ HStack(alignment: .center, spacing: 20) {
|
|
|
+ // Calculate total number of glucose readings
|
|
|
+ let total = Decimal(glucose.count)
|
|
|
+ // Count readings greater than high limit (180 mg/dL)
|
|
|
+ let high = glucose.filter { $0.glucose > Int(highLimit) }.count
|
|
|
+ // Count readings between low limit (TITR: 70 mg/dL, TING 63 mg/dL) and 140 mg/dL (tight control)
|
|
|
+ let tight = glucose
|
|
|
+ .filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= timeInRangeType.topThreshold }.count
|
|
|
+ // Count readings between 140 and high limit (normal range)
|
|
|
+ let normal = glucose.filter { $0.glucose >= timeInRangeType.bottomThreshold && $0.glucose <= Int(highLimit) }
|
|
|
+ .count
|
|
|
+ // Count readings less than low limit (low) (70 mg/dL if not showing chart, otherwise 70 for TITR and 63 for TING)
|
|
|
+ let low = glucose.filter { $0.glucose < (showChart ? Int(timeInRangeType.bottomThreshold) : 70) }.count
|
|
|
+ // Count readings less than moderately low limit (63 mg/dL)
|
|
|
+ let moderatelyLow = glucose.filter { $0.glucose < 63 }.count
|
|
|
+ // Count readings less than moderately high limit (220 mg/dL)
|
|
|
+ let moderatelyHigh = glucose.filter { $0.glucose > 220 }.count
|
|
|
+ // Count readings less than very low limit (54 mg/dL)
|
|
|
+ let veryLow = glucose.filter { $0.glucose < 54 }.count
|
|
|
+ // Count readings less than very high limit (250 mg/dL)
|
|
|
+ let veryHigh = glucose.filter { $0.glucose > 250 }.count
|
|
|
+
|
|
|
+ let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
|
|
|
+ let sumReadings = justGlucoseArray.reduce(0, +)
|
|
|
+
|
|
|
+ let glucoseAverage = Decimal(sumReadings) / total
|
|
|
+ let medianGlucose = StatChartUtils.medianCalculation(array: justGlucoseArray)
|
|
|
+
|
|
|
+ let lowPercentage = Decimal(low) / total * 100
|
|
|
+ let tightPercentage = Decimal(tight) / total * 100
|
|
|
+ let inRangePercentage = Decimal(normal) / total * 100
|
|
|
+ let highPercentage = Decimal(high) / total * 100
|
|
|
+ let moderatelyLowPercentage = Decimal(moderatelyLow) / total * 100
|
|
|
+ let moderatelyHighPercentage = Decimal(moderatelyHigh) / total * 100
|
|
|
+ let veryLowPercentage = Decimal(veryLow) / total * 100
|
|
|
+ let veryHighPercentage = Decimal(veryHigh) / total * 100
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text(
|
|
|
+ "\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(highLimit.formatted(for: units))"
|
|
|
+ )
|
|
|
+ .font(.subheadline)
|
|
|
.foregroundStyle(Color.secondary)
|
|
|
- Text(inRangePercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
|
|
|
- .foregroundStyle(Color.loopGreen)
|
|
|
- }
|
|
|
+ Text(formatPercentage(inRangePercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.loopGreen)
|
|
|
+ }
|
|
|
|
|
|
- VStack(alignment: .leading, spacing: 5) {
|
|
|
- Text(
|
|
|
- "\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(Decimal(timeInRangeType.topThreshold)))"
|
|
|
- )
|
|
|
- .font(.subheadline)
|
|
|
- .foregroundStyle(Color.secondary)
|
|
|
- Text(tightPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
|
|
|
- .foregroundStyle(Color.green)
|
|
|
- }
|
|
|
- }.padding(.leading, 5)
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text(
|
|
|
+ "\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units))"
|
|
|
+ )
|
|
|
+ .font(.subheadline)
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+ Text(formatPercentage(tightPercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.green)
|
|
|
+ }
|
|
|
+ }.padding(.leading, 5)
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text("> \(highLimit.formatted(for: units))").font(.subheadline)
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+ Text(formatPercentage(highPercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.loopYellow)
|
|
|
+ }
|
|
|
|
|
|
- VStack(alignment: .leading, spacing: 10) {
|
|
|
- VStack(alignment: .leading, spacing: 5) {
|
|
|
- Text("> \(formatValue(highLimit))").font(.subheadline)
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text(
|
|
|
+ "< \(Decimal(showChart ? timeInRangeType.bottomThreshold : 70).formatted(for: units))"
|
|
|
+ )
|
|
|
+ .font(.subheadline)
|
|
|
.foregroundStyle(Color.secondary)
|
|
|
- Text(highPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
|
|
|
- .foregroundStyle(Color.orange)
|
|
|
+ Text(formatPercentage(lowPercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.red)
|
|
|
+ }
|
|
|
}
|
|
|
+ // If not showing chart, show extra stats
|
|
|
+ if !showChart {
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text("> \(Decimal(220).formatted(for: units))").font(.subheadline)
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+ Text(formatPercentage(moderatelyHighPercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.loopYellow)
|
|
|
+ }
|
|
|
|
|
|
- VStack(alignment: .leading, spacing: 5) {
|
|
|
- Text(
|
|
|
- "< \(formatValue(Decimal(timeInRangeType.bottomThreshold)))"
|
|
|
- )
|
|
|
- .font(.subheadline)
|
|
|
- .foregroundStyle(Color.secondary)
|
|
|
- Text(lowPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
|
|
|
- .foregroundStyle(Color.loopRed)
|
|
|
- }
|
|
|
- }
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text(
|
|
|
+ "< \(Decimal(63).formatted(for: units))"
|
|
|
+ )
|
|
|
+ .font(.subheadline)
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+ Text(formatPercentage(moderatelyLowPercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.red)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text("> \(Decimal(250).formatted(for: units))").font(.subheadline)
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+ Text(formatPercentage(veryHighPercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.orange)
|
|
|
+ }
|
|
|
|
|
|
- VStack(alignment: .leading, spacing: 10) {
|
|
|
- VStack(alignment: .leading, spacing: 5) {
|
|
|
- Text("Average").font(.subheadline).foregroundStyle(Color.secondary)
|
|
|
- Text(
|
|
|
- units == .mgdL ? glucoseAverage
|
|
|
- .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : glucoseAverage.asMmolL
|
|
|
- .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
|
|
|
- )
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text(
|
|
|
+ "< \(Decimal(54).formatted(for: units))"
|
|
|
+ )
|
|
|
+ .font(.subheadline)
|
|
|
+ .foregroundStyle(Color.secondary)
|
|
|
+ Text(formatPercentage(veryLowPercentage, tight: true))
|
|
|
+ .foregroundStyle(Color.purple)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- VStack(alignment: .leading, spacing: 5) {
|
|
|
- Text("Median").font(.subheadline).foregroundStyle(Color.secondary)
|
|
|
- Text(
|
|
|
- units == .mgdL ? medianGlucose
|
|
|
- .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : medianGlucose.asMmolL
|
|
|
- .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
|
|
|
- )
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text(showChart ? "Average" : "Avg").font(.subheadline).foregroundStyle(Color.secondary)
|
|
|
+ Text(
|
|
|
+ units == .mgdL ? glucoseAverage
|
|
|
+ .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : glucoseAverage
|
|
|
+ .asMmolL
|
|
|
+ .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 5) {
|
|
|
+ Text(showChart ? "Median" : "Med").font(.subheadline).foregroundStyle(Color.secondary)
|
|
|
+ Text(
|
|
|
+ units == .mgdL ? medianGlucose
|
|
|
+ .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : medianGlucose
|
|
|
+ .asMmolL
|
|
|
+ .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
|
|
|
+ )
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- Chart {
|
|
|
- ForEach(rangeData, id: \.range) { data in
|
|
|
- SectorMark(
|
|
|
- angle: .value("Percentage", data.count),
|
|
|
- innerRadius: .ratio(0.618),
|
|
|
- outerRadius: selectedRange == data.range ? 100 : 80,
|
|
|
- angularInset: 1.5
|
|
|
- )
|
|
|
- .foregroundStyle(data.color)
|
|
|
+ if showChart {
|
|
|
+ Chart {
|
|
|
+ ForEach(rangeData, id: \.range) { data in
|
|
|
+ SectorMark(
|
|
|
+ angle: .value("Percentage", data.count),
|
|
|
+ innerRadius: .ratio(0.618),
|
|
|
+ outerRadius: selectedRange == data.range ? 100 : 80
|
|
|
+ )
|
|
|
+ .foregroundStyle(data.color)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .chartAngleSelection(value: $selectedCount)
|
|
|
+ .frame(height: 100)
|
|
|
}
|
|
|
}
|
|
|
- .chartAngleSelection(value: $selectedCount)
|
|
|
- .frame(height: 100)
|
|
|
- }
|
|
|
- .onChange(of: selectedCount) { _, newValue in
|
|
|
- if let newValue {
|
|
|
- withAnimation {
|
|
|
- getSelectedRange(value: newValue)
|
|
|
- }
|
|
|
- } else {
|
|
|
- withAnimation {
|
|
|
- selectedRange = nil
|
|
|
+ .onChange(of: selectedCount) { _, newValue in
|
|
|
+ if let newValue {
|
|
|
+ withAnimation {
|
|
|
+ getSelectedRange(value: newValue)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ withAnimation {
|
|
|
+ selectedRange = nil
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- }
|
|
|
- .overlay(alignment: .top) {
|
|
|
- if let selectedRange {
|
|
|
- let data = getDetailedData(for: selectedRange)
|
|
|
- RangeDetailPopover(data: data)
|
|
|
- .transition(.scale.combined(with: .opacity))
|
|
|
- .offset(y: -150) // TODO: make this dynamic
|
|
|
+ .overlay(alignment: .top) {
|
|
|
+ if let selectedRange {
|
|
|
+ let data = getDetailedData(for: selectedRange)
|
|
|
+ RangeDetailPopover(data: data)
|
|
|
+ .transition(.scale.combined(with: .opacity))
|
|
|
+ .offset(y: -150) // TODO: make this dynamic
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -167,7 +230,7 @@ struct GlucoseSectorChart: View {
|
|
|
|
|
|
// Return array of tuples with range data
|
|
|
return [
|
|
|
- (.high, highCount, Decimal(highCount) / Decimal(total) * 100, .orange),
|
|
|
+ (.high, highCount, Decimal(highCount) / Decimal(total) * 100, .loopYellow),
|
|
|
(.inRange, inRangeCount, Decimal(inRangeCount) / Decimal(total) * 100, .green),
|
|
|
(.low, lowCount, Decimal(lowCount) / Decimal(total) * 100, .red)
|
|
|
]
|
|
|
@@ -216,15 +279,18 @@ struct GlucoseSectorChart: View {
|
|
|
|
|
|
return RangeDetail(
|
|
|
title: String(localized: "High Glucose"),
|
|
|
- color: .orange,
|
|
|
+ color: .loopYellow,
|
|
|
items: [
|
|
|
- (String(localized: "Very High (>\(formatValue(250)))"), formatPercentage(Decimal(veryHigh) / total * 100)),
|
|
|
(
|
|
|
- String(localized: "High (\(formatValue(highLimit))-\(formatValue(250)))"),
|
|
|
+ String(localized: "Very High (>\(Decimal(250).formatted(for: units)))"),
|
|
|
+ formatPercentage(Decimal(veryHigh) / total * 100)
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ String(localized: "High (\(highLimit.formatted(for: units))-\(Decimal(250).formatted(for: units)))"),
|
|
|
formatPercentage(Decimal(high) / total * 100)
|
|
|
),
|
|
|
- (String(localized: "Average"), formatValue(average)),
|
|
|
- (String(localized: "Median"), formatValue(median)),
|
|
|
+ (String(localized: "Average"), average.formatted(for: units)),
|
|
|
+ (String(localized: "Median"), median.formatted(for: units)),
|
|
|
(String(localized: "SD"), formatSD(standardDeviation))
|
|
|
]
|
|
|
)
|
|
|
@@ -242,18 +308,18 @@ struct GlucoseSectorChart: View {
|
|
|
items: [
|
|
|
(
|
|
|
String(
|
|
|
- localized: "Normal (\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(highLimit)))"
|
|
|
+ localized: "Normal (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(highLimit.formatted(for: units)))"
|
|
|
),
|
|
|
formatPercentage(Decimal(glucoseValues.count) / total * 100)
|
|
|
),
|
|
|
(
|
|
|
String(
|
|
|
- localized: "\(timeInRangeType == .timeInTightRange ? "TITR" : "TING") (\(formatValue(Decimal(timeInRangeType.bottomThreshold)))-\(formatValue(Decimal(timeInRangeType.topThreshold))))"
|
|
|
+ localized: "\(timeInRangeType == .timeInTightRange ? "TITR" : "TING") (\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units))-\(Decimal(timeInRangeType.topThreshold).formatted(for: units)))"
|
|
|
),
|
|
|
formatPercentage(Decimal(tight) / total * 100)
|
|
|
),
|
|
|
- (String(localized: "Average"), formatValue(average)),
|
|
|
- (String(localized: "Median"), formatValue(median)),
|
|
|
+ (String(localized: "Average"), average.formatted(for: units)),
|
|
|
+ (String(localized: "Median"), median.formatted(for: units)),
|
|
|
(String(localized: "SD"), formatSD(standardDeviation))
|
|
|
]
|
|
|
)
|
|
|
@@ -271,12 +337,17 @@ struct GlucoseSectorChart: View {
|
|
|
color: .red,
|
|
|
items: [
|
|
|
(
|
|
|
- String(localized: "Low (\(formatValue(54))-\(formatValue(Decimal(timeInRangeType.bottomThreshold))))"),
|
|
|
+ String(
|
|
|
+ localized: "Low (\(Decimal(54).formatted(for: units))-\(Decimal(timeInRangeType.bottomThreshold).formatted(for: units)))"
|
|
|
+ ),
|
|
|
formatPercentage(Decimal(low) / total * 100)
|
|
|
),
|
|
|
- (String(localized: "Very Low (<\(formatValue(54)))"), formatPercentage(Decimal(veryLow) / total * 100)),
|
|
|
- (String(localized: "Average"), formatValue(average)),
|
|
|
- (String(localized: "Median"), formatValue(median)),
|
|
|
+ (
|
|
|
+ String(localized: "Very Low (<\(Decimal(54).formatted(for: units))"),
|
|
|
+ formatPercentage(Decimal(veryLow) / total * 100)
|
|
|
+ ),
|
|
|
+ (String(localized: "Average"), average.formatted(for: units)),
|
|
|
+ (String(localized: "Median"), median.formatted(for: units)),
|
|
|
(String(localized: "SD"), formatSD(standardDeviation))
|
|
|
]
|
|
|
)
|
|
|
@@ -286,10 +357,14 @@ struct GlucoseSectorChart: View {
|
|
|
/// Formats a percentage value to a string with one decimal place.
|
|
|
/// - Parameter value: A decimal value representing the percentage.
|
|
|
/// - Returns: A formatted percentage string
|
|
|
- private func formatPercentage(_ value: Decimal) -> String {
|
|
|
+ private func formatPercentage(_ value: Decimal, tight: Bool = false) -> String {
|
|
|
let formatter = NumberFormatter()
|
|
|
formatter.numberStyle = .percent
|
|
|
- formatter.maximumFractionDigits = 1
|
|
|
+ formatter.minimumFractionDigits = value == 100 ? 0 : 1
|
|
|
+ formatter.maximumFractionDigits = value == 100 ? 0 : 1
|
|
|
+ if tight {
|
|
|
+ formatter.positiveSuffix = "%"
|
|
|
+ }
|
|
|
return formatter.string(from: NSDecimalNumber(decimal: value / 100)) ?? "0%"
|
|
|
}
|
|
|
|
|
|
@@ -319,13 +394,6 @@ struct GlucoseSectorChart: View {
|
|
|
.number.grouping(.never).rounded().precision(.fractionLength(0))
|
|
|
) : sd.formattedAsMmolL
|
|
|
}
|
|
|
-
|
|
|
- /// Formats a glucose value based on the current units.
|
|
|
- /// - Parameter value: A decimal value representing the glucose level.
|
|
|
- /// - Returns: A formatted string of the glucose value.
|
|
|
- private func formatValue(_ value: Decimal) -> String {
|
|
|
- units == .mgdL ? value.description : value.formattedAsMmolL
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
/// Represents details about a specific glucose range category including title, color and percentage breakdowns
|