From 02f6e724dd815723e7d9fcf3bfdc91eba1b013c4 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 1/6] 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 | 6 ++- .../LoopFollowLiveActivity.swift | 40 ++++++++++---- Shared/GlucoseConversion.swift | 5 ++ 7 files changed, 122 insertions(+), 18 deletions(-) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 525fcfcb2..f43a77e0d 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -273,9 +273,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 3d32a51da..c09d9135d 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -111,6 +111,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 0d3e61fe5..5c8e55478 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 @@ -44,6 +45,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 @@ -211,6 +224,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 @@ -433,6 +484,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 b4af72f72..c10935eec 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -861,9 +861,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 a9018ca94..ca5764061 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -809,36 +809,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 - GlucoseThresholds.veryLow, 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 4dbfecd5d970d1eb94368f21002935466ce7f227 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 2/6] 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 f43a77e0d..23fed5036 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -273,14 +273,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 c09d9135d..2266dee5c 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -111,25 +111,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 5c8e55478..9dc7ca082 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -52,9 +52,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) } @@ -224,44 +222,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 c10935eec..71167539f 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -860,15 +860,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 ca5764061..90de47f42 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -809,27 +809,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 6b1a086f8f875926914cb94c7c4ead6c3857614f 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/6] 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 +- LoopFollow/Helpers/Views/BGPicker.swift | 7 +- .../BGDisplayPrecisionTests.swift | 94 +++++++++++++++++++ 5 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 Tests/AlarmConditions/BGDisplayPrecisionTests.swift diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 242d84069..2f82b69e1 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -27,7 +27,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 @@ -51,7 +52,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 2d48fe25f..134c4649d 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -33,8 +33,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/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 ce453e0abe318a96ee86fbe3aadaa75beefdb116 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 4/6] 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 14c299fe61c5009c05ca0952e158359109f4d681 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 5/6] 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 1ba47008193b415dc7d42329537ffd1378421ea3 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 6/6] 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 | 18 ++++-- Shared/GlucoseConversion.swift | 6 +- Tests/GlucoseBandTests.swift | 28 +++++----- Tests/StatsBoundaryTests.swift | 70 ++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 27 deletions(-) create mode 100644 Tests/StatsBoundaryTests.swift diff --git a/LoopFollow/Stats/GRI/GRICalculator.swift b/LoopFollow/Stats/GRI/GRICalculator.swift index fd170f55b..6efbbff7f 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 a27eef414..08a5c9d30 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -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 + } + } +}