Skip to content

Commit 640b3ec

Browse files
Phase 7: Dual-mode glucose chart, HealthKit data pipeline, and quality fixes
Glucose chart now operates in two modes: standard Ambulatory Glucose Profile (24-hour overlay with percentile bands) for 14-day lookback, and Glucose Profile (multi-day time series) for all other periods. Both modes include an info button explaining the visualization. HealthKit glucose data supplements Loop store for longer analysis periods. Chart data clears on period change to prevent stale labels. Additional fixes across 22 files: improved HealthKit data pipeline reliability, enhanced test data provider, refined food response analysis, and minor bug fixes in background monitor, coordinator, caffeine tracker, and goals/trends views.
1 parent 1e74a3c commit 640b3ec

22 files changed

Lines changed: 374 additions & 199 deletions

Loop/Localizable.xcstrings

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -629,18 +629,6 @@
629629
}
630630
}
631631
},
632-
"%@ [%lld chars]" : {
633-
"comment" : "A view that displays a meal with its food type, date, and glucose response. The \"Pre-Meal Advice\" tab in the Loop Insights app uses this view to show individual meal cards.",
634-
"isCommentAutoGenerated" : true,
635-
"localizations" : {
636-
"en" : {
637-
"stringUnit" : {
638-
"state" : "new",
639-
"value" : "%1$@ [%2$lld chars]"
640-
}
641-
}
642-
}
643-
},
644632
"%@ %@" : {
645633
"comment" : "The format for an active custom preset. (1: preset symbol)(2: preset name)",
646634
"localizations" : {
@@ -5969,6 +5957,9 @@
59695957
"AGP Chart" : {
59705958
"comment" : "LoopInsights AGP toggle"
59715959
},
5960+
"AGP is a standardized reporting format developed by the International Diabetes Center. It overlays 14 days of CGM data into a single 24-hour view, displaying the median (P50), interquartile range (P25–P75), and 10th/90th percentile bands.\n\nThis format lets you and your clinician spot recurring daily patterns — like dawn phenomenon or post-meal spikes — at a glance, using the same visual language across institutions." : {
5961+
"comment" : "LoopInsights AGP info alert message"
5962+
},
59725963
"AI Advice" : {
59735964
"comment" : "LoopInsights AI advice header"
59745965
},
@@ -6607,7 +6598,7 @@
66076598
"comment" : "LoopInsights quick ask: meal bolus"
66086599
},
66096600
"Ambulatory Glucose Profile" : {
6610-
"comment" : "LoopInsights AGP chart title"
6601+
"comment" : "LoopInsights AGP chart title\nLoopInsights AGP info alert title"
66116602
},
66126603
"Amount (mg)" : {
66136604
"comment" : "LoopInsights caffeine amount\nLoopInsights caffeine amount placeholder"
@@ -21002,6 +20993,12 @@
2100220993
}
2100320994
}
2100420995
},
20996+
"Glucose Profile" : {
20997+
"comment" : "LoopInsights glucose profile chart title\nLoopInsights glucose profile info alert title"
20998+
},
20999+
"Glucose Profile displays your CGM data across the selected time period using percentile bands.\n\nThe median line (P50) shows your typical glucose at each point in time. The shaded bands show the interquartile range (P25–P75) and the 10th/90th percentile spread, giving you a sense of variability.\n\nFor a standardized Ambulatory Glucose Profile (AGP) — which overlays all days into a single 24-hour view — select the 14-day lookback period." : {
21000+
"comment" : "LoopInsights glucose profile info alert message"
21001+
},
2100521002
"Glucose Target Range Schedule" : {
2100621003
"comment" : "Details for configuration error when glucose target range schedule is missing",
2100721004
"localizations" : {
@@ -28685,7 +28682,7 @@
2868528682
"Not enough\ndata available" : {
2868628683
"comment" : "LoopInsights GMI insufficient data"
2868728684
},
28688-
"Not enough data for AGP chart" : {
28685+
"Not enough data for glucose profile" : {
2868928686
"comment" : "LoopInsights AGP no data"
2869028687
},
2869128688
"Notification Delivery" : {

Loop/Managers/LoopInsights/LoopInsights_BackgroundMonitor.swift

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import Foundation
1010
import UserNotifications
1111
import Combine
12+
import os.log
1213

1314
/// Monitors Loop's completion cycle and periodically runs AI analysis
1415
/// to proactively detect therapy setting adjustment opportunities.
@@ -54,7 +55,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
5455
func start() {
5556
guard loopCompletedObserver == nil else { return }
5657
guard LoopInsights_FeatureFlags.backgroundMonitorEnabled else {
57-
print("[LoopInsights Monitor] Background monitoring is disabled")
58+
LoopInsights_FeatureFlags.log.info("Background monitoring is disabled")
5859
return
5960
}
6061

@@ -66,7 +67,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
6667
self?.handleLoopCompleted()
6768
}
6869

69-
print("[LoopInsights Monitor] Started — frequency: \(LoopInsights_FeatureFlags.monitorFrequency.displayName)")
70+
LoopInsights_FeatureFlags.log.info("Monitor started — frequency: \(LoopInsights_FeatureFlags.monitorFrequency.displayName)")
7071
}
7172

7273
/// Stop observing and cancel any pending work.
@@ -75,7 +76,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
7576
NotificationCenter.default.removeObserver(observer)
7677
loopCompletedObserver = nil
7778
}
78-
print("[LoopInsights Monitor] Stopped")
79+
LoopInsights_FeatureFlags.log.info("Monitor stopped")
7980
}
8081

8182
/// Restart the monitor (e.g. after settings change).
@@ -136,7 +137,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
136137
// MARK: - Background Analysis
137138

138139
private func runBackgroundAnalysis() async {
139-
print("[LoopInsights Monitor] Running background analysis...")
140+
LoopInsights_FeatureFlags.log.debug("Running background analysis...")
140141

141142
do {
142143
let period = LoopInsights_FeatureFlags.analysisPeriod
@@ -163,7 +164,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
163164
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.lastAnalysisKey)
164165

165166
guard !newSuggestions.isEmpty else {
166-
print("[LoopInsights Monitor] No new suggestions found")
167+
LoopInsights_FeatureFlags.log.debug("No new suggestions found")
167168
return
168169
}
169170

@@ -177,7 +178,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
177178
}
178179

179180
guard !genuinelyNew.isEmpty else {
180-
print("[LoopInsights Monitor] Suggestions match existing pending — no notification needed")
181+
LoopInsights_FeatureFlags.log.debug("Suggestions match existing pending — no notification needed")
181182
return
182183
}
183184

@@ -188,7 +189,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
188189
await deliverNotification(for: genuinelyNew)
189190

190191
} catch {
191-
print("[LoopInsights Monitor] Analysis failed: \(error.localizedDescription)")
192+
LoopInsights_FeatureFlags.log.error("Background analysis failed: \(error.localizedDescription)")
192193
}
193194
}
194195

@@ -199,7 +200,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
199200

200201
// Check quiet hours — still store suggestions but suppress notifications
201202
if isInQuietHours() {
202-
print("[LoopInsights Monitor] Quiet hours active — suppressing notification")
203+
LoopInsights_FeatureFlags.log.info("Quiet hours active — suppressing notification")
203204
return
204205
}
205206

@@ -212,7 +213,7 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
212213
await deliverPushNotification(suggestions: suggestions)
213214
case .silent:
214215
// No notification — suggestions are available in the store
215-
print("[LoopInsights Monitor] Silent mode — \(suggestions.count) suggestion(s) stored")
216+
LoopInsights_FeatureFlags.log.debug("Silent mode — \(suggestions.count) suggestion(s) stored")
216217
}
217218
}
218219

@@ -251,9 +252,9 @@ final class LoopInsights_BackgroundMonitor: ObservableObject {
251252

252253
do {
253254
try await UNUserNotificationCenter.current().add(request)
254-
print("[LoopInsights Monitor] Push notification sent for \(suggestions.count) suggestion(s)")
255+
LoopInsights_FeatureFlags.log.info("Push notification sent for \(suggestions.count) suggestion(s)")
255256
} catch {
256-
print("[LoopInsights Monitor] Failed to send notification: \(error.localizedDescription)")
257+
LoopInsights_FeatureFlags.log.error("Failed to send notification: \(error.localizedDescription)")
257258
}
258259
}
259260

Loop/Managers/LoopInsights/LoopInsights_Coordinator.swift

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,11 @@ final class LoopInsights_Coordinator: ObservableObject {
9595

9696
let provider = LoopInsights_TestDataProvider()
9797
guard provider.hasTestData else {
98-
print("[LoopInsights] Test data mode enabled but no fixtures found")
98+
LoopInsights_FeatureFlags.log.info("Test data mode enabled but no fixtures found")
9999
return nil
100100
}
101101

102-
print("[LoopInsights] Using test data: \(provider.dataSummary)")
102+
LoopInsights_FeatureFlags.log.info("Using test data: \(provider.dataSummary)")
103103
return LoopInsights_Coordinator(testDataProvider: provider)
104104
}
105105

@@ -108,7 +108,7 @@ final class LoopInsights_Coordinator: ObservableObject {
108108
/// Start background monitoring if enabled and using real stores (not test data).
109109
func startBackgroundMonitoring() {
110110
guard dataProviderBridge != nil else {
111-
print("[LoopInsights] Skipping background monitor — test data mode")
111+
LoopInsights_FeatureFlags.log.debug("Skipping background monitor — test data mode")
112112
return
113113
}
114114
backgroundMonitor.start()
@@ -138,7 +138,8 @@ final class LoopInsights_Coordinator: ObservableObject {
138138
// P3: Use pre-fetched glucose, fall back to bridge only if not provided
139139
var resolvedGlucose: [StoredGlucoseSample]? = glucoseSamples
140140
if resolvedGlucose == nil, let bridge = dataProviderBridge {
141-
resolvedGlucose = try? await bridge.getGlucoseSamples(start: start, end: end)
141+
do { resolvedGlucose = try await bridge.getGlucoseSamples(start: start, end: end) }
142+
catch { LoopInsights_FeatureFlags.log.error("Supplemental context: glucose fetch failed: \(error)") }
142143
}
143144

144145
// Circadian + Dawn Phenomenon + Negative Basal + Stress
@@ -171,7 +172,8 @@ final class LoopInsights_Coordinator: ObservableObject {
171172
// P3: Use pre-fetched carbs, fall back to bridge only if not provided
172173
var resolvedCarbs: [StoredCarbEntry]? = carbEntries
173174
if resolvedCarbs == nil, let bridge = dataProviderBridge {
174-
resolvedCarbs = try? await bridge.getCarbEntries(start: start, end: end)
175+
do { resolvedCarbs = try await bridge.getCarbEntries(start: start, end: end) }
176+
catch { LoopInsights_FeatureFlags.log.error("Supplemental context: carbs fetch failed: \(error)") }
175177
}
176178
if let carbs = resolvedCarbs, let glucSamples = resolvedGlucose {
177179
let patterns = LoopInsights_FoodResponseAnalyzer.analyzeFoodResponses(
@@ -224,7 +226,7 @@ final class LoopInsights_Coordinator: ObservableObject {
224226
@discardableResult
225227
func applyTherapyChanges(suggestion: LoopInsightsSuggestion) -> Bool {
226228
guard let writer = settingsWriter else {
227-
print("[LoopInsights] Cannot apply: no settings writer available (test data mode?)")
229+
LoopInsights_FeatureFlags.log.error("Cannot apply: no settings writer available (test data mode?)")
228230
return false
229231
}
230232

@@ -256,7 +258,7 @@ final class LoopInsights_Coordinator: ObservableObject {
256258
}
257259
}
258260

259-
print("[LoopInsights] Applied \(suggestion.settingType.displayName) changes: \(blocks.count) time block(s)")
261+
LoopInsights_FeatureFlags.log.info("Applied \(suggestion.settingType.displayName) changes: \(blocks.count) time block(s)")
260262
return true
261263
}
262264

@@ -266,7 +268,7 @@ final class LoopInsights_Coordinator: ObservableObject {
266268
@discardableResult
267269
func revertToSnapshot(_ snapshot: LoopInsightsTherapySnapshot) -> Bool {
268270
guard let writer = settingsWriter else {
269-
print("[LoopInsights] Cannot revert: no settings writer available")
271+
LoopInsights_FeatureFlags.log.error("Cannot revert: no settings writer available")
270272
return false
271273
}
272274

@@ -305,7 +307,7 @@ final class LoopInsights_Coordinator: ObservableObject {
305307
}
306308
}
307309

308-
print("[LoopInsights] Reverted settings to previous snapshot")
310+
LoopInsights_FeatureFlags.log.info("Reverted settings to previous snapshot")
309311
return true
310312
}
311313

Loop/Models/LoopInsights/LoopInsights_Models.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,3 +869,22 @@ final class LoopInsightsChatSession: ObservableObject {
869869
messages.removeAll()
870870
}
871871
}
872+
873+
// MARK: - Binary Search Utility
874+
875+
extension Array {
876+
/// Binary search for the first index in a sorted array where `keyPath` is at or after `date`.
877+
/// The array must be sorted by the key in ascending order.
878+
func loopInsights_firstIndex(afterOrAt date: Date, by dateExtractor: (Element) -> Date) -> Int {
879+
var lo = 0, hi = count
880+
while lo < hi {
881+
let mid = (lo + hi) / 2
882+
if dateExtractor(self[mid]) < date {
883+
lo = mid + 1
884+
} else {
885+
hi = mid
886+
}
887+
}
888+
return lo
889+
}
890+
}

Loop/Models/LoopInsights/LoopInsights_Phase5Models.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,9 @@ struct LoopInsightsNightscoutTreatment: Codable {
222222

223223
// MARK: - AGP Data Point
224224

225-
/// A single time point in an Ambulatory Glucose Profile
225+
/// A single time-window in a glucose profile chart spanning the analysis period.
226226
struct LoopInsightsAGPDataPoint {
227-
let minuteOfDay: Int // 0-1439
227+
let date: Date // Bucket midpoint
228228
let p10: Double // 10th percentile mg/dL
229229
let p25: Double // 25th percentile mg/dL
230230
let p50: Double // 50th (median) mg/dL

Loop/Resources/LoopInsights/LoopInsights_FeatureFlags.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77
//
88

99
import Foundation
10+
import os.log
1011

1112
/// Runtime feature flags for LoopInsights. All flags are UserDefaults-backed
1213
/// so they can be toggled without recompilation.
1314
struct LoopInsights_FeatureFlags {
1415

16+
/// Shared logger for all LoopInsights subsystems
17+
static let log = Logger(subsystem: "com.loopkit.Loop.LoopInsights", category: "LoopInsights")
18+
1519
private enum Keys {
1620
static let isEnabled = "LoopInsights_isEnabled"
1721
static let developerModeEnabled = "LoopInsights_developerModeEnabled"

Loop/Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ final class LoopInsights_AdvancedAnalyzers {
156156
// P5: Binary search for overcorrection check instead of linear scan
157157
let checkStart = dose.endDate
158158
let checkEnd = dose.endDate.addingTimeInterval(2 * 3600)
159-
let startIdx = Self.binarySearchFirstIndex(in: sortedGlucose, afterOrAt: checkStart)
159+
let startIdx = sortedGlucose.loopInsights_firstIndex(afterOrAt: checkStart) { $0.startDate }
160160
var reboundHigh = false
161161
for i in startIdx..<sortedGlucose.count {
162162
let sample = sortedGlucose[i]
@@ -292,20 +292,4 @@ final class LoopInsights_AdvancedAnalyzers {
292292
return result
293293
}
294294

295-
/// P5: Binary search to find the first glucose sample at or after `date` in a sorted array.
296-
static func binarySearchFirstIndex(
297-
in samples: [StoredGlucoseSample],
298-
afterOrAt date: Date
299-
) -> Int {
300-
var lo = 0, hi = samples.count
301-
while lo < hi {
302-
let mid = (lo + hi) / 2
303-
if samples[mid].startDate < date {
304-
lo = mid + 1
305-
} else {
306-
hi = mid
307-
}
308-
}
309-
return lo
310-
}
311295
}

Loop/Services/LoopInsights/LoopInsights_CaffeineTracker.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ final class LoopInsights_CaffeineTracker: ObservableObject {
5050
self.rebuildMergedEntries()
5151
}
5252
} catch {
53-
print("[LoopInsights] Failed to fetch HealthKit caffeine: \(error)")
53+
LoopInsights_FeatureFlags.log.error("Failed to fetch HealthKit caffeine: \(error)")
5454
}
5555
}
5656

0 commit comments

Comments
 (0)