From f50640890808a33f153bd55f232d0e11bf0a2967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 27 Sep 2026 13:35:15 +0200 Subject: [PATCH 1/7] Use the selected range mode for Live Activity colors The Live Activity now colors the reading against the same low and high values as the chart and BG header, so TIR and TITR use 70-180 and 70-140 and Custom uses the user's lines. A reading exactly on a line counts as low or high, like in the header. Changing the range mode refreshes the Live Activity. --- .../LiveActivity/LiveActivityManager.swift | 24 +++++++++---------- .../ViewControllers/MainViewController.swift | 1 + .../LoopFollowLiveActivity.swift | 12 +++++----- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 125ba4e09..e8eb43de3 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -182,10 +182,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) seq += 1 @@ -624,10 +621,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() LAAppGroupSettings.setDisplayName( Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", show: Storage.shared.showDisplayName.value @@ -904,6 +898,12 @@ final class LiveActivityManager { } } + /// Publishes the range for the selected time-in-range mode, matching the chart and BG header. + private func writeThresholds() { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high) + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -928,12 +928,10 @@ final class LiveActivityManager { let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 // Capture dedup result BEFORE saving so the store comparison is valid. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + let thresholdsChanged = LAAppGroupSettings.thresholdsMgdl() != UnitSettingsStore.shared.effectiveThresholds() // Store + Watch: always update, independent of LA state. - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) // WatchConnectivityManager.shared.send(snapshot: snapshot) @@ -946,7 +944,7 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)") return } - guard !snapshotUnchanged || forceRefreshNeeded else { return } + guard !snapshotUnchanged || thresholdsChanged || forceRefreshNeeded else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)") return diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..05d96f309 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -282,6 +282,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { .sink { [weak self] _ in self?.updateBGGraphSettings() self?.updateBGGraph() + LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed") } .store(in: &cancellables) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 6d438531e..177529a92 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -779,12 +779,12 @@ private enum LAColors { let low = t.low let high = t.high - if mgdl < low { - let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) + if mgdl <= low { + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - 54.0, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl > high { - let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) + } else if mgdl >= high { + let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / max(324.0 - high, 1)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) } else { @@ -798,9 +798,9 @@ private enum LAColors { let low = t.low let high = t.high - if mgdl < low { + if mgdl <= low { return Color(uiColor: UIColor.systemRed) - } else if mgdl > high { + } else if mgdl >= high { return Color(uiColor: UIColor.systemOrange) } else { return Color(uiColor: UIColor.systemGreen) From 1aeb549f4e86ebe53964086ab007ed7c290828b1 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:54:49 -0400 Subject: [PATCH 2/7] Fix range and mmol/L colors in chart and LA - Push an immediate Live Activity update when the custom low/high lines change. TIR/TITR use fixed ranges, so this only applies in custom mode. Thresholds are published through writeThresholds() so the effective range is always used. - Refresh the main stats pie when the range lines or range mode change. - Compare reading colors at mmol/L display precision, so two readings that both display as "3.9" get the same color. Applies to the chart, BG text, contact image and the Live Activity widget. mg/dL is unchanged. - Share the rounding via GlucoseConversion.toDisplayedMmol. --- LoopFollow/Charts/BGChartModel.swift | 6 ++- LoopFollow/Contact/ContactColorMode.swift | 12 +++-- LoopFollow/Helpers/Localizer.swift | 19 +++++++ .../LiveActivity/LiveActivityManager.swift | 52 +++++++++++++++++++ .../ViewControllers/MainViewController.swift | 11 ++-- .../LoopFollowLiveActivity.swift | 40 ++++++++++---- Shared/GlucoseConversion.swift | 5 ++ 7 files changed, 126 insertions(+), 19 deletions(-) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..fb29dad07 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -261,9 +261,11 @@ final class BGChartModel: ObservableObject { } private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color { - if Double(sgv) >= thresholds.high { + let normalize = UnitSettingsStore.shared.normalizeForColorComparison + let bgValue = normalize(Double(sgv)) + if bgValue >= normalize(thresholds.high) { return .yellow - } else if Double(sgv) <= thresholds.low { + } else if bgValue <= normalize(thresholds.low) { return .red } else { return .green diff --git a/LoopFollow/Contact/ContactColorMode.swift b/LoopFollow/Contact/ContactColorMode.swift index 7d146040c..f1b86c3d6 100644 --- a/LoopFollow/Contact/ContactColorMode.swift +++ b/LoopFollow/Contact/ContactColorMode.swift @@ -16,18 +16,20 @@ enum ContactColorMode: String, Codable, CaseIterable { } } - /// Returns the appropriate text color based on the mode and BG value + /// Returns the appropriate text color based on the mode and BG value (in mg/dL). func textColor(for bgValue: Double, staticColor: UIColor) -> UIColor { switch self { case .staticColor: return staticColor case .dynamic: - let highLine = Storage.shared.highLine.value - let lowLine = Storage.shared.lowLine.value + let normalize = UnitSettingsStore.shared.normalizeForColorComparison + let normalizedBG = normalize(bgValue) + let highLine = normalize(Storage.shared.highLine.value) + let lowLine = normalize(Storage.shared.lowLine.value) - if bgValue >= highLine { + if normalizedBG >= highLine { return .systemYellow - } else if bgValue <= lowLine { + } else if normalizedBG <= lowLine { return .systemRed } else { return .systemGreen diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index b615a4cf6..365c4ee20 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -74,6 +74,25 @@ final class UnitSettingsStore { } } + /// Normalizes a mg/dL glucose value to the precision of the current display unit. + /// + /// When the display unit is mmol/L, values are shown rounded to one decimal place. + /// This means two distinct mg/dL readings (e.g. 70 and 71) can both display as the + /// same mmol/L string (e.g. "3.9"). Comparing raw mg/dL values against a threshold + /// in that case produces a color that doesn't match the displayed number. + /// + /// Call this on both the reading *and* the threshold before doing a color comparison + /// so that the result is always consistent with what the user sees on screen. + func normalizeForColorComparison(_ mgdl: Double) -> Double { + switch glucoseUnit { + case .mgdL: + return mgdl + case .mmolL: + // Round to one decimal place, matching the display precision for mmol/L. + return GlucoseConversion.toDisplayedMmol(mgdl) + } + } + var glycemicMetricMode: GlycemicMetricMode { get { Storage.shared.showGMI.value ? .gmi : .ehba1c diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index e8eb43de3..5d97f9157 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -5,6 +5,7 @@ #if !targetEnvironment(macCatalyst) @preconcurrency import ActivityKit +import Combine import Foundation import os import UIKit @@ -42,6 +43,18 @@ final class LiveActivityManager { ) startPushToStartTokenObservation() startActivityUpdatesObservation() + + Publishers.Merge( + Storage.shared.lowLine.$value.map { _ in () }, + Storage.shared.highLine.$value.map { _ in () } + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleThresholdChange() + } + } + .store(in: &cancellables) } // MARK: - Push-to-start observation @@ -210,6 +223,44 @@ final class LiveActivityManager { } } + /// Pushes an immediate update when the custom low/high lines are edited. + /// TIR and TITR use fixed ranges, so the lines only affect LA colors in custom mode. + @MainActor + private func handleThresholdChange() { + guard UnitSettingsStore.shared.timeInRangeMode == .custom else { return } + + writeThresholds() + + guard Storage.shared.laEnabled.value, let activity = current else { return } + + let provider = StorageCurrentGlucoseStateProvider() + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } + + GlucoseSnapshotStore.shared.save(snapshot) + + seq += 1 + let nextSeq = seq + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: "threshold-changed", + producedAt: Date(), + ) + let content = ActivityContent( + state: state, + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + relevanceScore: 100.0, + ) + + Task { + await activity.update(content) + LogManager.shared.log(category: .general, message: "[LA] threshold-changed flush sent seq=\(nextSeq)", isDebug: true) + if let token = pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } let appState = UIApplication.shared.applicationState.rawValue @@ -427,6 +478,7 @@ final class LiveActivityManager { /// and arrives within a few seconds of the first request. private static let pushToStartTokenRetryDelay: TimeInterval = 10 + private var cancellables = Set() private(set) var current: Activity? private var stateObserverTask: Task? private var updateTask: Task? diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 05d96f309..f85aaad68 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -269,7 +269,9 @@ 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.lowLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.highLine.$value.map { _ in () }.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -283,6 +285,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { self?.updateBGGraphSettings() self?.updateBGGraph() LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed") + self?.updateStats() } .store(in: &cancellables) @@ -830,9 +833,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { let latestBG = bgData[bgData.count - 1].sgv if Storage.shared.colorBGText.value { let thresholds = UnitSettingsStore.shared.effectiveThresholds() - if Double(latestBG) >= thresholds.high { + let normalize = UnitSettingsStore.shared.normalizeForColorComparison + let bgValue = normalize(Double(latestBG)) + if bgValue >= normalize(thresholds.high) { Observable.shared.bgTextColor.value = .yellow - } else if Double(latestBG) <= thresholds.low { + } else if bgValue <= normalize(thresholds.low) { Observable.shared.bgTextColor.value = .red } else { Observable.shared.bgTextColor.value = .green diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 177529a92..71b210ebc 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -773,36 +773,58 @@ private enum LAFormat { // MARK: - Threshold-driven colors private enum LAColors { + /// Which side of the thresholds a reading falls on, compared at display precision + /// so the color always matches the number shown (two mg/dL values can both show "3.9"). + private enum Band { case low, high, inRange } + + private static func band(for snapshot: GlucoseSnapshot, low: Double, high: Double) -> Band { + let mgdl = snapshot.glucose + let value: Double + let lowLimit: Double + let highLimit: Double + switch snapshot.unit { + case .mgdl: + (value, lowLimit, highLimit) = (mgdl, low, high) + case .mmol: + value = GlucoseConversion.toDisplayedMmol(mgdl) + lowLimit = GlucoseConversion.toDisplayedMmol(low) + highLimit = GlucoseConversion.toDisplayedMmol(high) + } + + if value <= lowLimit { return .low } + if value >= highLimit { return .high } + return .inRange + } + static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high - if mgdl <= low { + switch band(for: snapshot, low: low, high: high) { + case .low: let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - 54.0, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl >= high { + case .high: let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / max(324.0 - high, 1)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) - } else { + case .inRange: return Color(uiColor: UIColor.systemGreen).opacity(0.36) } } static func keyline(for snapshot: GlucoseSnapshot) -> Color { - let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() - let low = t.low - let high = t.high - if mgdl <= low { + switch band(for: snapshot, low: t.low, high: t.high) { + case .low: return Color(uiColor: UIColor.systemRed) - } else if mgdl >= high { + case .high: return Color(uiColor: UIColor.systemOrange) - } else { + case .inRange: return Color(uiColor: UIColor.systemGreen) } } diff --git a/Shared/GlucoseConversion.swift b/Shared/GlucoseConversion.swift index dab205bfa..67def89a6 100644 --- a/Shared/GlucoseConversion.swift +++ b/Shared/GlucoseConversion.swift @@ -10,4 +10,9 @@ enum GlucoseConversion { static func toMmol(_ mgdl: Double) -> Double { mgdl * mgDlToMmolL } + + /// Converts mg/dL to mmol/L rounded to the one-decimal precision used for display. + static func toDisplayedMmol(_ mgdl: Double) -> Double { + (toMmol(mgdl) * 10).rounded() / 10 + } } From 0cab172b21c9cd6b1855b09deca7d6e6efee2b93 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:47:40 -0400 Subject: [PATCH 3/7] Address review: shared range band, LA threshold refresh path, pie boundary - Route custom low/high line changes through refreshFromCurrentState instead of a separate hand-built Live Activity push, so the dismissed, enabled and in-flight checks apply and picker bursts are debounced. - Use effectiveThresholds() for the contact image so TIR and TITR modes match the chart, BG text and Live Activity. - Add GlucoseBand.of(_:low:high:mmol:) in Shared and use it for the chart, BG text, contact image, stats pie and the Live Activity widget. A reading on a line is low or high, compared at display precision. - Make the stats pie follow the same boundary rule. - Rename normalizeForColorComparison to normalizeToDisplayPrecision and shorten its comment. --- LoopFollow/Charts/BGChartModel.swift | 12 ++---- LoopFollow/Contact/ContactColorMode.swift | 15 ++----- LoopFollow/Controllers/Stats.swift | 12 ++---- LoopFollow/Helpers/Localizer.swift | 20 ++++----- .../LiveActivity/LiveActivityManager.swift | 42 +------------------ .../ViewControllers/MainViewController.swift | 13 ++---- .../LoopFollowLiveActivity.swift | 23 +--------- Shared/GlucoseConversion.swift | 17 ++++++++ Tests/GlucoseBandTests.swift | 35 ++++++++++++++++ 9 files changed, 80 insertions(+), 109 deletions(-) create mode 100644 Tests/GlucoseBandTests.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index fb29dad07..3f2cdc74d 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -261,14 +261,10 @@ final class BGChartModel: ObservableObject { } private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color { - let normalize = UnitSettingsStore.shared.normalizeForColorComparison - let bgValue = normalize(Double(sgv)) - if bgValue >= normalize(thresholds.high) { - return .yellow - } else if bgValue <= normalize(thresholds.low) { - return .red - } else { - return .green + switch UnitSettingsStore.shared.glucoseBand(for: Double(sgv), thresholds: thresholds) { + case .high: return .yellow + case .low: return .red + case .inRange: return .green } } diff --git a/LoopFollow/Contact/ContactColorMode.swift b/LoopFollow/Contact/ContactColorMode.swift index f1b86c3d6..25ec4bc8a 100644 --- a/LoopFollow/Contact/ContactColorMode.swift +++ b/LoopFollow/Contact/ContactColorMode.swift @@ -22,17 +22,10 @@ enum ContactColorMode: String, Codable, CaseIterable { case .staticColor: return staticColor case .dynamic: - let normalize = UnitSettingsStore.shared.normalizeForColorComparison - let normalizedBG = normalize(bgValue) - let highLine = normalize(Storage.shared.highLine.value) - let lowLine = normalize(Storage.shared.lowLine.value) - - if normalizedBG >= highLine { - return .systemYellow - } else if normalizedBG <= lowLine { - return .systemRed - } else { - return .systemGreen + switch UnitSettingsStore.shared.glucoseBand(for: bgValue) { + case .high: return .systemYellow + case .low: return .systemRed + case .inRange: return .systemGreen } } } diff --git a/LoopFollow/Controllers/Stats.swift b/LoopFollow/Controllers/Stats.swift index 15447db9d..29358dc33 100644 --- a/LoopFollow/Controllers/Stats.swift +++ b/LoopFollow/Controllers/Stats.swift @@ -27,17 +27,13 @@ class StatsData { coefficientOfVariation = 0.0 let thresholds = UnitSettingsStore.shared.effectiveThresholds() - let lowThreshold = thresholds.low - let highThreshold = thresholds.high for i in 0 ..< bgData.count { // Set low/range/high counts for pie chart and %'s - if Double(bgData[i].sgv) < lowThreshold { - countLow += 1 - } else if Double(bgData[i].sgv) > highThreshold { - countHigh += 1 - } else { - countRange += 1 + switch UnitSettingsStore.shared.glucoseBand(for: Double(bgData[i].sgv), thresholds: thresholds) { + case .low: countLow += 1 + case .high: countHigh += 1 + case .inRange: countRange += 1 } // set total bg for average diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index 365c4ee20..5e60ddbdb 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -74,25 +74,23 @@ final class UnitSettingsStore { } } - /// Normalizes a mg/dL glucose value to the precision of the current display unit. - /// - /// When the display unit is mmol/L, values are shown rounded to one decimal place. - /// This means two distinct mg/dL readings (e.g. 70 and 71) can both display as the - /// same mmol/L string (e.g. "3.9"). Comparing raw mg/dL values against a threshold - /// in that case produces a color that doesn't match the displayed number. - /// - /// Call this on both the reading *and* the threshold before doing a color comparison - /// so that the result is always consistent with what the user sees on screen. - func normalizeForColorComparison(_ mgdl: Double) -> Double { + /// Rounds a mg/dL value to the display precision of the current unit, so colors, + /// stats and alarms match the number shown. Apply to both the reading and the threshold. + func normalizeToDisplayPrecision(_ mgdl: Double) -> Double { switch glucoseUnit { case .mgdL: return mgdl case .mmolL: - // Round to one decimal place, matching the display precision for mmol/L. return GlucoseConversion.toDisplayedMmol(mgdl) } } + /// Band of a mg/dL reading against the selected range (or `thresholds` when given). + func glucoseBand(for mgdl: Double, thresholds: (low: Double, high: Double)? = nil) -> GlucoseBand { + let range = thresholds ?? effectiveThresholds() + return GlucoseBand.of(mgdl, low: range.low, high: range.high, mmol: glucoseUnit == .mmolL) + } + var glycemicMetricMode: GlycemicMetricMode { get { Storage.shared.showGMI.value ? .gmi : .ehba1c diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 5d97f9157..aede837a4 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -50,9 +50,7 @@ final class LiveActivityManager { ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in - Task { @MainActor [weak self] in - self?.handleThresholdChange() - } + self?.refreshFromCurrentState(reason: "thresholds changed") } .store(in: &cancellables) } @@ -223,44 +221,6 @@ final class LiveActivityManager { } } - /// Pushes an immediate update when the custom low/high lines are edited. - /// TIR and TITR use fixed ranges, so the lines only affect LA colors in custom mode. - @MainActor - private func handleThresholdChange() { - guard UnitSettingsStore.shared.timeInRangeMode == .custom else { return } - - writeThresholds() - - guard Storage.shared.laEnabled.value, let activity = current else { return } - - let provider = StorageCurrentGlucoseStateProvider() - guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - - GlucoseSnapshotStore.shared.save(snapshot) - - seq += 1 - let nextSeq = seq - let state = GlucoseLiveActivityAttributes.ContentState( - snapshot: snapshot, - seq: nextSeq, - reason: "threshold-changed", - producedAt: Date(), - ) - let content = ActivityContent( - state: state, - staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), - relevanceScore: 100.0, - ) - - Task { - await activity.update(content) - LogManager.shared.log(category: .general, message: "[LA] threshold-changed flush sent seq=\(nextSeq)", isDebug: true) - if let token = pushToken { - await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) - } - } - } - @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } let appState = UIApplication.shared.applicationState.rawValue diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index f85aaad68..61a84ff12 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -832,15 +832,10 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { if bgData.count > 0 { let latestBG = bgData[bgData.count - 1].sgv if Storage.shared.colorBGText.value { - let thresholds = UnitSettingsStore.shared.effectiveThresholds() - let normalize = UnitSettingsStore.shared.normalizeForColorComparison - let bgValue = normalize(Double(latestBG)) - if bgValue >= normalize(thresholds.high) { - Observable.shared.bgTextColor.value = .yellow - } else if bgValue <= normalize(thresholds.low) { - Observable.shared.bgTextColor.value = .red - } else { - Observable.shared.bgTextColor.value = .green + switch UnitSettingsStore.shared.glucoseBand(for: Double(latestBG)) { + case .high: Observable.shared.bgTextColor.value = .yellow + case .low: Observable.shared.bgTextColor.value = .red + case .inRange: Observable.shared.bgTextColor.value = .green } } else { Observable.shared.bgTextColor.value = .primary diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 71b210ebc..ca3312588 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -773,27 +773,8 @@ private enum LAFormat { // MARK: - Threshold-driven colors private enum LAColors { - /// Which side of the thresholds a reading falls on, compared at display precision - /// so the color always matches the number shown (two mg/dL values can both show "3.9"). - private enum Band { case low, high, inRange } - - private static func band(for snapshot: GlucoseSnapshot, low: Double, high: Double) -> Band { - let mgdl = snapshot.glucose - let value: Double - let lowLimit: Double - let highLimit: Double - switch snapshot.unit { - case .mgdl: - (value, lowLimit, highLimit) = (mgdl, low, high) - case .mmol: - value = GlucoseConversion.toDisplayedMmol(mgdl) - lowLimit = GlucoseConversion.toDisplayedMmol(low) - highLimit = GlucoseConversion.toDisplayedMmol(high) - } - - if value <= lowLimit { return .low } - if value >= highLimit { return .high } - return .inRange + private static func band(for snapshot: GlucoseSnapshot, low: Double, high: Double) -> GlucoseBand { + GlucoseBand.of(snapshot.glucose, low: low, high: high, mmol: snapshot.unit == .mmol) } static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { diff --git a/Shared/GlucoseConversion.swift b/Shared/GlucoseConversion.swift index 67def89a6..f82f3b562 100644 --- a/Shared/GlucoseConversion.swift +++ b/Shared/GlucoseConversion.swift @@ -16,3 +16,20 @@ enum GlucoseConversion { (toMmol(mgdl) * 10).rounded() / 10 } } + +/// Which side of the low/high lines a reading falls on. +enum GlucoseBand { + case low + case inRange + case high + + /// Classifies a mg/dL reading at the precision shown to the user, so the result + /// always matches the displayed number. A reading on a line is low or high. + static func of(_ mgdl: Double, low: Double, high: Double, mmol: Bool) -> GlucoseBand { + let display: (Double) -> Double = mmol ? GlucoseConversion.toDisplayedMmol : { $0 } + let value = display(mgdl) + if value <= display(low) { return .low } + if value >= display(high) { return .high } + return .inRange + } +} diff --git a/Tests/GlucoseBandTests.swift b/Tests/GlucoseBandTests.swift new file mode 100644 index 000000000..d32ed07b5 --- /dev/null +++ b/Tests/GlucoseBandTests.swift @@ -0,0 +1,35 @@ +// LoopFollow +// GlucoseBandTests.swift + +@testable import LoopFollow +import Testing + +struct GlucoseBandTests { + @Test("mg/dL: a reading on a line is low or high") + func mgdlBoundaries() { + #expect(GlucoseBand.of(69, low: 70, high: 180, mmol: false) == .low) + #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: false) == .low) + #expect(GlucoseBand.of(71, low: 70, high: 180, mmol: false) == .inRange) + #expect(GlucoseBand.of(179, low: 70, high: 180, mmol: false) == .inRange) + #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: false) == .high) + } + + @Test("mmol/L: readings that display the same value get the same band") + func mmolSameDisplayedValue() { + // 70 and 71 mg/dL both display 3.9; 180 and 181 both display 10.0. + #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: true) == .low) + #expect(GlucoseBand.of(71, low: 70, high: 180, mmol: true) == .low) + #expect(GlucoseBand.of(72, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(179, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: true) == .high) + #expect(GlucoseBand.of(181, low: 70, high: 180, mmol: true) == .high) + } + + @Test("mmol/L: a line stored with the old x18 picker factor behaves like the picked value") + func mmolLegacyPickerLine() { + // 3.9 mmol/L picked with x18 is 70.2 mg/dL. + #expect(GlucoseBand.of(70, low: 70.2, high: 180, mmol: true) == .low) + #expect(GlucoseBand.of(71, low: 70.2, high: 180, mmol: true) == .low) + #expect(GlucoseBand.of(72, low: 70.2, high: 180, mmol: true) == .inRange) + } +} From 184b0c2b2c2d71ee6317133d85a802dfae266833 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:47:40 -0400 Subject: [PATCH 4/7] Compare alarm thresholds at display precision and unify picker factor - High, Low, activation limits and missed bolus alarms now round the reading and the threshold to the display precision of the current unit, so an alarm agrees with the number shown in mmol/L. - BGPicker converts mmol/L to mg/dL with GlucoseConversion.mmolToMgDl instead of a hardcoded 18. Existing saved values need no migration because both sides are rounded before comparing. - Add tests for boundary readings with both conversion factors. --- .../Alarm/AlarmCondition/AlarmCondition.swift | 5 +- .../AlarmCondition/HighBGCondition.swift | 5 +- .../Alarm/AlarmCondition/LowBGCondition.swift | 5 +- .../AlarmCondition/MissedBolusCondition.swift | 6 +- LoopFollow/Helpers/Views/BGPicker.swift | 7 +- .../BGDisplayPrecisionTests.swift | 94 +++++++++++++++++++ 6 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 Tests/AlarmConditions/BGDisplayPrecisionTests.swift diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 8e60bcb9d..11dd7f027 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -22,7 +22,8 @@ extension AlarmCondition { func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool { let bgReading = data.bgReadings.last?.sgv let haveBG = (bgReading ?? 0) > 0 - let bgValue = Double(bgReading ?? 0) + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let bgValue = normalize(Double(bgReading ?? 0)) // ──────────────────────────────────── // 1. BG-based alarms always need data @@ -46,7 +47,7 @@ extension AlarmCondition { // BG-based alarms already bailed out above. guard haveBG else { return false } - switch (alarm.belowBG, alarm.aboveBG) { + switch (alarm.belowBG.map(normalize), alarm.aboveBG.map(normalize)) { case let (lo?, hi?): return lo < hi ? (bgValue <= lo || bgValue >= hi) // fire outside band : (hi <= bgValue && bgValue <= lo) // fire inside band diff --git a/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift index 6ba8b9b47..8884e6e96 100644 --- a/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift @@ -14,8 +14,11 @@ struct HighBGCondition: AlarmCondition { // ──────────────────────────────── guard let high = alarm.aboveBG else { return false } + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let highLimit = normalize(high) + func isHigh(_ g: GlucoseValue) -> Bool { - g.sgv > 0 && Double(g.sgv) >= high + g.sgv > 0 && normalize(Double(g.sgv)) >= highLimit } // we already know from `passesBGLimits` that the **latest** reading is ≥ high, diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index cb4610f74..eb5e7a029 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -22,8 +22,11 @@ struct LowBGCondition: AlarmCondition { guard let belowBG = alarm.belowBG else { return false } guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false } + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let lowLimit = normalize(belowBG) + func isLow(_ g: GlucoseValue) -> Bool { - g.sgv > 0 && Double(g.sgv) <= belowBG + g.sgv > 0 && normalize(Double(g.sgv)) <= lowLimit } // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift index 53bbeabc4..fcd9a4a48 100644 --- a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift @@ -37,8 +37,12 @@ struct MissedBolusCondition: AlarmCondition { guard carb.grams > minCarbGr else { return false } // – ignore if BG is low + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision if let latestBG = data.bgReadings.last, - Double(latestBG.sgv) <= minBG { return false } + normalize(Double(latestBG.sgv)) <= normalize(minBG) + { + return false + } // ──────────────────────────────── // 2. already alerted for this carb? diff --git a/LoopFollow/Helpers/Views/BGPicker.swift b/LoopFollow/Helpers/Views/BGPicker.swift index 86c99a706..0a7c9fd20 100644 --- a/LoopFollow/Helpers/Views/BGPicker.swift +++ b/LoopFollow/Helpers/Views/BGPicker.swift @@ -17,9 +17,10 @@ struct BGPicker: View { private var allValues: [Double] { if unit == .millimolesPerLiter { let step = 0.1 - let lower = ceil((range.lowerBound / 18) / step) * step - let upper = floor((range.upperBound / 18) / step) * step - return stride(from: lower, through: upper, by: step).map { $0 * 18 } + let factor = GlucoseConversion.mmolToMgDl + let lower = ceil((range.lowerBound / factor) / step) * step + let upper = floor((range.upperBound / factor) / step) * step + return stride(from: lower, through: upper, by: step).map { $0 * factor } } else { return Array(stride(from: range.lowerBound, through: range.upperBound, diff --git a/Tests/AlarmConditions/BGDisplayPrecisionTests.swift b/Tests/AlarmConditions/BGDisplayPrecisionTests.swift new file mode 100644 index 000000000..18bb4f0fc --- /dev/null +++ b/Tests/AlarmConditions/BGDisplayPrecisionTests.swift @@ -0,0 +1,94 @@ +// LoopFollow +// BGDisplayPrecisionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +/// In mmol/L, two mg/dL readings that display the same value must get the same +/// alarm result, and a threshold picked as X.X mmol/L must fire for a reading +/// that displays as X.X, regardless of which conversion factor stored it. +@Suite(.serialized) +struct BGDisplayPrecisionTests { + private func withMmol(_ body: () -> Void) { + let previous = Storage.shared.units.value + Storage.shared.units.value = GlucoseDisplayUnit.mmolL.rawValue + defer { Storage.shared.units.value = previous } + body() + } + + private func highAlarm(aboveBG: Double) -> Alarm { + var alarm = Alarm(type: .high) + alarm.aboveBG = aboveBG + return alarm + } + + private func fires(_ alarm: Alarm, sgv: Int) -> Bool { + let data = AlarmData.withReadings([sgv]) + if alarm.type == .high { + return HighBGCondition().evaluate(alarm: alarm, data: data, now: Date()) + } + return LowBGCondition().evaluate(alarm: alarm, data: data, now: Date()) + } + + @Test("high 10.0 mmol/L fires for both mg/dL readings that display 10.0") + func highBoundaryDisplaysAsLimit() { + withMmol { + // 10.0 mmol/L stored with the shared factor and with the old x18 picker factor. + for stored in [10.0 * GlucoseConversion.mmolToMgDl, 10.0 * 18] { + let alarm = highAlarm(aboveBG: stored) + #expect(!fires(alarm, sgv: 179)) + #expect(fires(alarm, sgv: 180)) + #expect(fires(alarm, sgv: 181)) + #expect(fires(alarm, sgv: 182)) + } + } + } + + @Test("high 7.8 mmol/L fires at 140, which displays 7.8, even when stored as 140.4") + func highLegacyPickerValue() { + withMmol { + let alarm = highAlarm(aboveBG: 7.8 * 18) + #expect(!fires(alarm, sgv: 139)) + #expect(fires(alarm, sgv: 140)) + #expect(fires(alarm, sgv: 141)) + } + } + + @Test("low 3.9 mmol/L fires for both mg/dL readings that display 3.9") + func lowBoundaryDisplaysAsLimit() { + withMmol { + for stored in [3.9 * GlucoseConversion.mmolToMgDl, 3.9 * 18] { + let alarm = Alarm.low(belowBG: stored) + #expect(fires(alarm, sgv: 69)) + #expect(fires(alarm, sgv: 70)) + #expect(fires(alarm, sgv: 71)) + #expect(!fires(alarm, sgv: 72)) + } + } + } + + @Test("mg/dL keeps exact integer comparison") + func mgdlUnchanged() { + let previous = Storage.shared.units.value + Storage.shared.units.value = GlucoseDisplayUnit.mgdL.rawValue + defer { Storage.shared.units.value = previous } + + #expect(fires(highAlarm(aboveBG: 180), sgv: 180)) + #expect(!fires(highAlarm(aboveBG: 180), sgv: 179)) + #expect(fires(Alarm.low(belowBG: 70), sgv: 70)) + #expect(!fires(Alarm.low(belowBG: 70), sgv: 71)) + } + + @Test("every mmol/L picker value round-trips to the value that was picked") + func pickerValuesRoundTrip() { + withMmol { + for tenths in 20 ... 400 { + let picked = Double(tenths) / 10 + let stored = picked * GlucoseConversion.mmolToMgDl + let normalized = UnitSettingsStore.shared.normalizeToDisplayPrecision(stored) + #expect(Int((normalized * 10).rounded()) == tenths) + } + } + } +} From ece46ecbbd7c2f1bfd30621558da6e1854a6545f Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:54:54 -0400 Subject: [PATCH 5/7] Fix GlucoseBand build error from mixed ternary Replace the function-reference/closure ternary with plain per-value conversions so the compiler can type-check it. --- Shared/GlucoseConversion.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Shared/GlucoseConversion.swift b/Shared/GlucoseConversion.swift index f82f3b562..21e86449d 100644 --- a/Shared/GlucoseConversion.swift +++ b/Shared/GlucoseConversion.swift @@ -26,10 +26,12 @@ enum GlucoseBand { /// Classifies a mg/dL reading at the precision shown to the user, so the result /// always matches the displayed number. A reading on a line is low or high. static func of(_ mgdl: Double, low: Double, high: Double, mmol: Bool) -> GlucoseBand { - let display: (Double) -> Double = mmol ? GlucoseConversion.toDisplayedMmol : { $0 } - let value = display(mgdl) - if value <= display(low) { return .low } - if value >= display(high) { return .high } + let value = mmol ? GlucoseConversion.toDisplayedMmol(mgdl) : mgdl + let lowLimit = mmol ? GlucoseConversion.toDisplayedMmol(low) : low + let highLimit = mmol ? GlucoseConversion.toDisplayedMmol(high) : high + + if value <= lowLimit { return .low } + if value >= highLimit { return .high } return .inRange } } From 747f622d1273bd7698bb737529238128b610ac17 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:55:06 -0400 Subject: [PATCH 6/7] Wrap GlucoseBand if bodies for SwiftFormat --- Shared/GlucoseConversion.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Shared/GlucoseConversion.swift b/Shared/GlucoseConversion.swift index 21e86449d..1af126e51 100644 --- a/Shared/GlucoseConversion.swift +++ b/Shared/GlucoseConversion.swift @@ -30,8 +30,12 @@ enum GlucoseBand { let lowLimit = mmol ? GlucoseConversion.toDisplayedMmol(low) : low let highLimit = mmol ? GlucoseConversion.toDisplayedMmol(high) : high - if value <= lowLimit { return .low } - if value >= highLimit { return .high } + if value <= lowLimit { + return .low + } + if value >= highLimit { + return .high + } return .inRange } } From c671619910b5e6dc50fb87a3e6157aa7f9b7993a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:17:57 -0400 Subject: [PATCH 7/7] Treat readings on a range line as in range A reading exactly on the low or high line is now in range, matching the international consensus on time in range (70-180 mg/dL, 3.9-10.0 mmol/L). GlucoseBand, and so the chart, BG text, contact image, stats pie and Live Activity widget, use the inclusive rule at display precision. The TIR screen and GRI keep their inclusive bands and published cutoffs but now compare at display precision, so readings that display the same mmol/L value share a band. Alarm thresholds are unchanged. --- LoopFollow/Stats/GRI/GRICalculator.swift | 17 ++++-- LoopFollow/Stats/TIR/TIRCalculator.swift | 20 +++++-- Shared/GlucoseConversion.swift | 6 +- Tests/GlucoseBandTests.swift | 28 +++++----- Tests/StatsBoundaryTests.swift | 70 ++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 28 deletions(-) create mode 100644 Tests/StatsBoundaryTests.swift diff --git a/LoopFollow/Stats/GRI/GRICalculator.swift b/LoopFollow/Stats/GRI/GRICalculator.swift index 1d84e17cc..35c45c2fc 100644 --- a/LoopFollow/Stats/GRI/GRICalculator.swift +++ b/LoopFollow/Stats/GRI/GRICalculator.swift @@ -25,16 +25,23 @@ class GRICalculator { var highCount = 0 var vHighCount = 0 + // Compare at display precision so readings that show the same value share a band. + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let vLowLimit = normalize(vLowThreshold) + let lowLimit = normalize(lowThreshold) + let highLimit = normalize(highThreshold) + let vHighLimit = normalize(vHighThreshold) + for reading in bgData { - let glucose = Double(reading.sgv) + let glucose = normalize(Double(reading.sgv)) - if glucose < vLowThreshold { + if glucose < vLowLimit { vLowCount += 1 - } else if glucose < lowThreshold { + } else if glucose < lowLimit { lowCount += 1 - } else if glucose > vHighThreshold { + } else if glucose > vHighLimit { vHighCount += 1 - } else if glucose > highThreshold { + } else if glucose > highLimit { highCount += 1 } } diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift index b387b5702..0c1ed1ff5 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -91,7 +91,7 @@ class TIRCalculator { return tirPoints } - private static func calculatePercentages(readings: [Double], + static func calculatePercentages(readings: [Double], veryLowThreshold: Double, lowThreshold: Double, highThreshold: Double, @@ -108,14 +108,22 @@ class TIRCalculator { var highCount = 0 var veryHighCount = 0 - for glucose in readings { - if glucose < veryLowThreshold { + // Compare at display precision so readings that show the same value share a band. + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let veryLowLimit = normalize(veryLowThreshold) + let lowLimit = normalize(lowThreshold) + let highLimit = normalize(highThreshold) + let veryHighLimit = normalize(veryHighThreshold) + + for reading in readings { + let glucose = normalize(reading) + if glucose < veryLowLimit { veryLowCount += 1 - } else if glucose < lowThreshold { + } else if glucose < lowLimit { lowCount += 1 - } else if glucose > veryHighThreshold { + } else if glucose > veryHighLimit { veryHighCount += 1 - } else if glucose > highThreshold { + } else if glucose > highLimit { highCount += 1 } else { inRangeCount += 1 diff --git a/Shared/GlucoseConversion.swift b/Shared/GlucoseConversion.swift index 1af126e51..7fac1fb06 100644 --- a/Shared/GlucoseConversion.swift +++ b/Shared/GlucoseConversion.swift @@ -24,16 +24,16 @@ enum GlucoseBand { case high /// Classifies a mg/dL reading at the precision shown to the user, so the result - /// always matches the displayed number. A reading on a line is low or high. + /// always matches the displayed number. A reading on a line is in range. static func of(_ mgdl: Double, low: Double, high: Double, mmol: Bool) -> GlucoseBand { let value = mmol ? GlucoseConversion.toDisplayedMmol(mgdl) : mgdl let lowLimit = mmol ? GlucoseConversion.toDisplayedMmol(low) : low let highLimit = mmol ? GlucoseConversion.toDisplayedMmol(high) : high - if value <= lowLimit { + if value < lowLimit { return .low } - if value >= highLimit { + if value > highLimit { return .high } return .inRange diff --git a/Tests/GlucoseBandTests.swift b/Tests/GlucoseBandTests.swift index d32ed07b5..89a2319aa 100644 --- a/Tests/GlucoseBandTests.swift +++ b/Tests/GlucoseBandTests.swift @@ -5,31 +5,31 @@ import Testing struct GlucoseBandTests { - @Test("mg/dL: a reading on a line is low or high") + @Test("mg/dL: a reading on a line is in range") func mgdlBoundaries() { #expect(GlucoseBand.of(69, low: 70, high: 180, mmol: false) == .low) - #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: false) == .low) + #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: false) == .inRange) #expect(GlucoseBand.of(71, low: 70, high: 180, mmol: false) == .inRange) - #expect(GlucoseBand.of(179, low: 70, high: 180, mmol: false) == .inRange) - #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: false) == .high) + #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: false) == .inRange) + #expect(GlucoseBand.of(181, low: 70, high: 180, mmol: false) == .high) } @Test("mmol/L: readings that display the same value get the same band") func mmolSameDisplayedValue() { // 70 and 71 mg/dL both display 3.9; 180 and 181 both display 10.0. - #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: true) == .low) - #expect(GlucoseBand.of(71, low: 70, high: 180, mmol: true) == .low) - #expect(GlucoseBand.of(72, low: 70, high: 180, mmol: true) == .inRange) - #expect(GlucoseBand.of(179, low: 70, high: 180, mmol: true) == .inRange) - #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: true) == .high) - #expect(GlucoseBand.of(181, low: 70, high: 180, mmol: true) == .high) + #expect(GlucoseBand.of(69, low: 70, high: 180, mmol: true) == .low) // 3.8 + #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(71, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(181, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(182, low: 70, high: 180, mmol: true) == .high) // 10.1 } @Test("mmol/L: a line stored with the old x18 picker factor behaves like the picked value") func mmolLegacyPickerLine() { - // 3.9 mmol/L picked with x18 is 70.2 mg/dL. - #expect(GlucoseBand.of(70, low: 70.2, high: 180, mmol: true) == .low) - #expect(GlucoseBand.of(71, low: 70.2, high: 180, mmol: true) == .low) - #expect(GlucoseBand.of(72, low: 70.2, high: 180, mmol: true) == .inRange) + // 3.9 mmol/L picked with x18 is 70.2 mg/dL; 10.0 mmol/L is 180.0. + #expect(GlucoseBand.of(69, low: 70.2, high: 180, mmol: true) == .low) + #expect(GlucoseBand.of(70, low: 70.2, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(71, low: 70.2, high: 180, mmol: true) == .inRange) } } diff --git a/Tests/StatsBoundaryTests.swift b/Tests/StatsBoundaryTests.swift new file mode 100644 index 000000000..ffb0dfd43 --- /dev/null +++ b/Tests/StatsBoundaryTests.swift @@ -0,0 +1,70 @@ +// LoopFollow +// StatsBoundaryTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +/// TIR and GRI use inclusive bands (70-180 is in range), compared at the display +/// precision of the current unit. +@Suite(.serialized) +struct StatsBoundaryTests { + private func withUnit(_ unit: GlucoseDisplayUnit, _ body: () -> Void) { + let previous = Storage.shared.units.value + Storage.shared.units.value = unit.rawValue + defer { Storage.shared.units.value = previous } + body() + } + + private let readings: [Double] = [53, 54, 69, 70, 71, 180, 181, 182, 250, 251] + + private func tir() -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) { + TIRCalculator.calculatePercentages( + readings: readings, + veryLowThreshold: 54, + lowThreshold: 70, + highThreshold: 180, + veryHighThreshold: 250 + ) + } + + @Test("TIR mg/dL: 70 and 180 are in range, 54 and 250 are not extreme") + func tirMgdl() { + withUnit(.mgdL) { + let p = tir() + #expect(p.veryLow == 10) // 53 + #expect(p.low == 20) // 54, 69 + #expect(p.inRange == 30) // 70, 71, 180 + #expect(p.high == 30) // 181, 182, 250 + #expect(p.veryHigh == 10) // 251 + } + } + + @Test("TIR mmol/L: readings that display the same value share a band") + func tirMmol() { + withUnit(.mmolL) { + let p = tir() + #expect(p.veryLow == 10) // 53 -> 2.9 + #expect(p.low == 20) // 54 -> 3.0, 69 -> 3.8 + #expect(p.inRange == 40) // 70, 71 -> 3.9; 180, 181 -> 10.0 + #expect(p.high == 30) // 182 -> 10.1; 250, 251 -> 13.9 + #expect(p.veryHigh == 0) + } + } + + @Test("GRI: on-a-line readings are not counted as low or high; display precision applies") + func gri() { + let data = [71, 181].map { ShareGlucoseData(sgv: $0, date: 0, direction: nil) } + + withUnit(.mgdL) { + let result = GRICalculator.calculate(bgData: data) + #expect(result.hypoComponent == 0) + #expect(result.hyperComponent == 25) // 181 is above 180: 0.5 x 50% + } + withUnit(.mmolL) { + let result = GRICalculator.calculate(bgData: data) + #expect(result.hypoComponent == 0) + #expect(result.hyperComponent == 0) // 181 displays 10.0 + } + } +}