diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index f3c4c0d10..1f10d25fb 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -4,21 +4,34 @@ import Foundation extension MainViewController { + /// Start of the period both home screen stats cover: the last 24 hours, or since + /// midnight when chosen in Settings. Midnight follows the display time zone, so a + /// Time Zone Override is respected. + func homeStatsPeriodStart() -> TimeInterval { + if Storage.shared.statsSinceMidnight.value { + return dateTimeUtils.displayCalendar().startOfDay(for: Date()).timeIntervalSince1970 + } + return dateTimeUtils.getTimeIntervalNHoursAgo(N: 24) + } + func updateStats() { - if bgData.count > 0 { - var lastDayOfData = bgData - let graphHours = 24 * Storage.shared.downloadDays.value - // If we loaded more than 1 day of data, only use the last day for the stats - if graphHours > 24 { - let oneDayAgo = dateTimeUtils.getTimeIntervalNHoursAgo(N: 24) - var startIndex = 0 - while startIndex < bgData.count, bgData[startIndex].date < oneDayAgo { - startIndex += 1 - } - lastDayOfData = Array(bgData.dropFirst(startIndex)) - } + let periodStart = homeStatsPeriodStart() + let periodData = bgData.filter { $0.date >= periodStart } - let stats = StatsData(bgData: lastDayOfData) + if periodData.isEmpty { + // Nothing in the period yet (e.g. just after midnight): show placeholders + // rather than the previous period's numbers. + statsDisplayModel.lowPercent = "--" + statsDisplayModel.inRangePercent = "--" + statsDisplayModel.highPercent = "--" + statsDisplayModel.avgBG = "--" + statsDisplayModel.estA1C = "--" + statsDisplayModel.stdDev = "--" + statsDisplayModel.pieLow = 0 + statsDisplayModel.pieRange = 0 + statsDisplayModel.pieHigh = 0 + } else { + let stats = StatsData(bgData: periodData) statsDisplayModel.lowPercent = String(format: "%.1f%%", stats.percentLow) statsDisplayModel.inRangePercent = String(format: "%.1f%%", stats.percentRange) @@ -48,5 +61,36 @@ extension MainViewController { statsDisplayModel.pieRange = Double(stats.percentRange) statsDisplayModel.pieHigh = Double(stats.percentHigh) } + updateTIRBand(periodStart: periodStart) + } + + /// Range distribution for the Time in Range band over the same period as the + /// statistics box (last 24 hours, or today since midnight). + /// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR + /// or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or + /// the range's own bounds when a custom range extends past them. + func updateTIRBand(periodStart: TimeInterval) { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + let values = bgData + .filter { $0.date >= periodStart && $0.sgv > 0 } + .map { Double($0.sgv) } + + statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeTitle + + let percentages = TIRCalculator.calculatePercentages( + readings: values, + veryLowThreshold: thresholds.veryLow, + lowThreshold: thresholds.low, + highThreshold: thresholds.high, + veryHighThreshold: thresholds.veryHigh + ) + + statsDisplayModel.bandVeryLowPct = percentages.veryLow + statsDisplayModel.bandLowPct = percentages.low + statsDisplayModel.bandInRangePct = percentages.inRange + statsDisplayModel.bandHighPct = percentages.high + statsDisplayModel.bandVeryHighPct = percentages.veryHigh + statsDisplayModel.bandHasData = !values.isEmpty + statsDisplayModel.bandPeriod = Storage.shared.statsSinceMidnight.value ? "today" : "last 24h" } } diff --git a/LoopFollow/Controllers/StatsDisplayModel.swift b/LoopFollow/Controllers/StatsDisplayModel.swift index 72e60560a..425a4487b 100644 --- a/LoopFollow/Controllers/StatsDisplayModel.swift +++ b/LoopFollow/Controllers/StatsDisplayModel.swift @@ -15,4 +15,14 @@ class StatsDisplayModel: ObservableObject { @Published var pieLow: Double = 0 @Published var pieRange: Double = 0 @Published var pieHigh: Double = 0 + + // Time in Range band (same period as the statistics box) + @Published var bandTitle: String = "Time in Range" + @Published var bandPeriod: String = "last 24h" + @Published var bandHasData: Bool = false + @Published var bandVeryLowPct: Double = 0 + @Published var bandLowPct: Double = 0 + @Published var bandInRangePct: Double = 0 + @Published var bandHighPct: Double = 0 + @Published var bandVeryHighPct: Double = 0 } diff --git a/LoopFollow/Controllers/TIRBandView.swift b/LoopFollow/Controllers/TIRBandView.swift new file mode 100644 index 000000000..dfb301088 --- /dev/null +++ b/LoopFollow/Controllers/TIRBandView.swift @@ -0,0 +1,99 @@ +// LoopFollow +// TIRBandView.swift + +import SwiftUI + +/// Time in Range band for the home screen: today's in-range +/// percentage above a segmented bar (very low / low / in range / high / very high). +/// The range follows the Range Mode in Settings (TIR, TITR or Custom). +/// Tapping opens the statistics screen. +struct TIRBandView: View { + @ObservedObject var model: StatsDisplayModel + var onTap: (() -> Void)? + + private var percentText: String { + guard model.bandHasData else { return "-- %" } + return model.bandInRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" + } + + private var segments: [(color: Color, fraction: CGFloat)] { + guard model.bandHasData else { return [(Color.secondary.opacity(0.3), 1)] } + return [ + (TIRGraphView.Band.veryLow.color, CGFloat(model.bandVeryLowPct / 100)), + (TIRGraphView.Band.low.color, CGFloat(model.bandLowPct / 100)), + (TIRGraphView.Band.inRange.color, CGFloat(model.bandInRangePct / 100)), + (TIRGraphView.Band.high.color, CGFloat(model.bandHighPct / 100)), + (TIRGraphView.Band.veryHigh.color, CGFloat(model.bandVeryHighPct / 100)), + ] + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(percentText) + .font(.title2) + .fontWeight(.bold) + .fontDesign(.rounded) + .foregroundStyle(.primary) + (Text(model.bandTitle).fontWeight(.semibold) + Text(" " + model.bandPeriod)) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + DistributionBar(segments: segments) + .frame(height: 6) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .frame(height: 64) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemBackground)) + ) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + } +} + +/// Horizontal bar of capsules, one per non-empty segment, sized by share. +/// Every non-empty segment is at least `minSegmentWidth` wide so a single +/// reading stays visible. +private struct DistributionBar: View { + let segments: [(color: Color, fraction: CGFloat)] + + private let spacing: CGFloat = 2 + private let minSegmentWidth: CGFloat = 4 + + var body: some View { + GeometryReader { geo in + let shown = segments.filter { $0.fraction > 0 } + let available = max(geo.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0) + let widths = segmentWidths(shown.map(\.fraction), available: available) + HStack(spacing: spacing) { + ForEach(Array(shown.enumerated()), id: \.offset) { index, segment in + Capsule() + .fill(segment.color) + .frame(width: widths[index]) + } + } + .frame(maxHeight: .infinity) + } + } + + private func segmentWidths(_ fractions: [CGFloat], available: CGFloat) -> [CGFloat] { + var widths = fractions.map { max($0 * available, minSegmentWidth) } + let excess = widths.reduce(0, +) - available + if excess > 0, let widest = widths.indices.max(by: { widths[$0] < widths[$1] }) { + widths[widest] = max(widths[widest] - excess, minSegmentWidth) + } + return widths + } +} diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index b615a4cf6..e36c08153 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -74,6 +74,25 @@ final class UnitSettingsStore { } } + /// Returns the very low, low, high and very high thresholds (in mg/dL) for + /// the current range mode. Very low is 54 and very high 250, or the range's + /// own bounds when a custom range extends past them. + func effectiveBandThresholds() -> (veryLow: Double, low: Double, high: Double, veryHigh: Double) { + let range = effectiveThresholds() + return (min(54.0, range.low), range.low, range.high, max(250.0, range.high)) + } + + var timeInRangeTitle: String { + switch timeInRangeMode { + case .tir: + return "Time in Range" + case .titr: + return "Time in Tight Range" + case .custom: + return "Custom Range" + } + } + var glycemicMetricMode: GlycemicMetricMode { get { Storage.shared.showGMI.value ? .gmi : .ehba1c diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 0ac3acc69..073d9c72d 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -8,6 +8,8 @@ struct GeneralSettingsView: View { @ObservedObject var appBadge = Storage.shared.appBadge @ObservedObject var appearanceMode = Storage.shared.appearanceMode @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand + @ObservedObject var statsSinceMidnight = Storage.shared.statsSinceMidnight @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var screenlockSwitchState = Storage.shared.screenlockSwitchState @ObservedObject var showDisplayName = Storage.shared.showDisplayName @@ -44,7 +46,6 @@ struct GeneralSettingsView: View { Text(mode.displayName).tag(mode) } } - Toggle("Display Stats", isOn: $showStats.value) Toggle("Display Small Graph", isOn: $showSmallGraph.value) Toggle("Color BG Text", isOn: $colorBGText.value) Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) @@ -61,6 +62,24 @@ struct GeneralSettingsView: View { } } + Section { + Toggle("Display Stats", isOn: $showStats.value) + if showStats.value { + Picker("Stats Style", selection: $showTIRBand.value) { + Text("Statistics Box").tag(false) + Text("Time in Range Band").tag(true) + } + Picker("Stats Period", selection: $statsSinceMidnight.value) { + Text("Last 24 Hours").tag(false) + Text("Today (since midnight)").tag(true) + } + } + } header: { + Text("Statistics") + } footer: { + Text("The statistics box and the Time in Range band both cover this period. Today starts at midnight in your time zone, or in the Time Zone Override below when it is on.") + } + Section("Time Zone") { Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value) .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 93fd0fd93..1f1b28535 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -157,13 +157,15 @@ enum SettingsRoute: Hashable, Identifiable { SettingsLeaf("Display App Badge", ["badge"]), SettingsLeaf("Persistent Notification"), SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]), - SettingsLeaf("Display Stats"), SettingsLeaf("Display Small Graph"), SettingsLeaf("Color BG Text"), SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]), SettingsLeaf("Show Display Name"), SettingsLeaf("Snoozer emoji"), SettingsLeaf("Force portrait mode", ["orientation", "landscape"]), + SettingsLeaf("Display Stats", ["statistics"]), + SettingsLeaf("Stats Style", ["time in range band", "statistics box", "tir band", "progress bar"]), + SettingsLeaf("Stats Period", ["statistics period", "last 24 hours", "since midnight", "today", "midnight"]), SettingsLeaf("Time Zone Override", ["timezone"]), SettingsLeaf("Speak BG", ["voice", "speech"]), SettingsLeaf("Send anonymous usage stats", ["telemetry", "diagnostics"]), diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift index b387b5702..a27eef414 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -7,11 +7,11 @@ class TIRCalculator { static func calculate(bgData: [ShareGlucoseData]) -> [TIRDataPoint] { guard !bgData.isEmpty else { return [] } - let thresholds = UnitSettingsStore.shared.effectiveThresholds() - let veryLowThreshold = 54.0 + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + let veryLowThreshold = thresholds.veryLow let lowThreshold = thresholds.low let highThreshold = thresholds.high - let veryHighThreshold = 250.0 + let veryHighThreshold = thresholds.veryHigh var periodData: [TIRPeriod: [Double]] = [:] let calendar = dateTimeUtils.displayCalendar() @@ -91,11 +91,11 @@ class TIRCalculator { return tirPoints } - private static func calculatePercentages(readings: [Double], - veryLowThreshold: Double, - lowThreshold: Double, - highThreshold: Double, - veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) + static func calculatePercentages(readings: [Double], + veryLowThreshold: Double, + lowThreshold: Double, + highThreshold: Double, + veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) { let total = Double(readings.count) guard total > 0 else { diff --git a/LoopFollow/Stats/TIR/TIRGraphView.swift b/LoopFollow/Stats/TIR/TIRGraphView.swift index 03a84a14b..1eba0f767 100644 --- a/LoopFollow/Stats/TIR/TIRGraphView.swift +++ b/LoopFollow/Stats/TIR/TIRGraphView.swift @@ -7,7 +7,7 @@ import SwiftUI struct TIRGraphView: View { let tirData: [TIRDataPoint] - private enum Band: String, CaseIterable, Plottable { + enum Band: String, CaseIterable, Plottable { case veryLow = "Very Low" case low = "Low" case inRange = "In Range" diff --git a/LoopFollow/Stats/TIR/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift index 8ff66e273..c322cf59b 100644 --- a/LoopFollow/Stats/TIR/TIRView.swift +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -13,7 +13,7 @@ struct TIRView: View { ZStack(alignment: .topTrailing) { VStack(alignment: .leading, spacing: 12) { HStack { - Text(tirTitle) + Text(UnitSettingsStore.shared.timeInRangeTitle) .font(.caption) .foregroundColor(.secondary) Spacer() @@ -42,21 +42,25 @@ struct TIRView: View { label: "Very High (\(veryHighCutoffText))", percentage: average.veryHigh ) - TIRLegendItem( - color: .yellow, - label: "High (\(highCutoffText))", - percentage: average.high - ) + if hasHighBand { + TIRLegendItem( + color: .yellow, + label: "High (\(highCutoffText))", + percentage: average.high + ) + } TIRLegendItem( color: .green, label: "In Range (\(inRangeCutoffText))", percentage: average.inRange ) - TIRLegendItem( - color: .red.opacity(0.5), - label: "Low (\(lowCutoffText))", - percentage: average.low - ) + if hasLowBand { + TIRLegendItem( + color: .red.opacity(0.5), + label: "Low (\(lowCutoffText))", + percentage: average.low + ) + } TIRLegendItem( color: .red.opacity(0.8), label: "Very Low (\(veryLowCutoffText))", @@ -86,17 +90,6 @@ struct TIRView: View { .buttonStyle(PlainButtonStyle()) } - private var tirTitle: String { - switch UnitSettingsStore.shared.timeInRangeMode { - case .tir: - return "Time in Range" - case .titr: - return "Time in Tight Range" - case .custom: - return "Custom Range" - } - } - private func formatRange(_: Double) -> String { let thresholds = UnitSettingsStore.shared.effectiveThresholds() let glucoseUnit = UnitSettingsStore.shared.glucoseUnit @@ -108,19 +101,31 @@ struct TIRView: View { } private var lowThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().low) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().low) } private var highThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().high) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().high) } private var veryLowThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(54.0) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().veryLow) } private var veryHighThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(250.0) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().veryHigh) + } + + /// A custom range that reaches past the very low cutoff leaves no low band. + private var hasLowBand: Bool { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + return thresholds.veryLow < thresholds.low + } + + /// A custom range that reaches past the very high cutoff leaves no high band. + private var hasHighBand: Bool { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + return thresholds.high < thresholds.veryHigh } private var veryLowCutoffText: String { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dd63f6dd6..0530f8153 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -70,6 +70,11 @@ class Storage { var colorBGText = StorageValue(key: "colorBGText", defaultValue: true) var appearanceMode = StorageValue(key: "appearanceMode", defaultValue: .dark) var showStats = StorageValue(key: "showStats", defaultValue: true) + /// Show the home screen stats as a Time in Range band instead of the statistics box. + var showTIRBand = StorageValue(key: "showTIRBand", defaultValue: false) + /// Period both home screen stats (statistics box and Time in Range band) cover: + /// the last 24 hours, or today since midnight. + var statsSinceMidnight = StorageValue(key: "statsSinceMidnight", defaultValue: false) var useIFCC = StorageValue(key: "useIFCC", defaultValue: false) var showSmallGraph = StorageValue(key: "showSmallGraph", defaultValue: true) var screenlockSwitchState = StorageValue(key: "screenlockSwitchState", defaultValue: true) diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift index 599120f7d..5be5c0a2f 100644 --- a/LoopFollow/ViewControllers/MainHomeView.swift +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -13,6 +13,7 @@ struct MainHomeView: View { @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand @ObservedObject var hideInfoTable = Storage.shared.hideInfoTable @ObservedObject var smallGraphHeight = Storage.shared.smallGraphHeight @ObservedObject var url = Storage.shared.url @@ -64,7 +65,11 @@ struct MainHomeView: View { // Statistics if showStats.value { - StatsDisplayView(model: statsModel, onTap: onStatsTap) + if showTIRBand.value { + TIRBandView(model: statsModel, onTap: onStatsTap) + } else { + StatsDisplayView(model: statsModel, onTap: onStatsTap) + } } } .padding(8) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..08d112860 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -269,7 +269,12 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher() + Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.statsSinceMidnight.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lowLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.highLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneEnabled.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneIdentifier.$value.map { _ in () }.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -282,6 +287,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { .sink { [weak self] _ in self?.updateBGGraphSettings() self?.updateBGGraph() + self?.updateStats() } .store(in: &cancellables)