From f22851d88c2b0287a8369c0665ee5ca47282e221 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 18 Oct 2026 15:05:51 -0500 Subject: [PATCH 1/2] Show backfilled readings in Recent Readings Historical and clinical backfill readings were forwarded to Loop but never added to recentSamples, so the settings list skipped the gap that Loop's chart filled. Backfill samples now go into the list in lifeCount order, tagged with their source, without changing the Last Reading card. A realtime reading for the same minute replaces a backfilled one. The realtime dedupe set still considers realtime samples only, so forwarding is unchanged. --- .../LibreLoopCGMManager.swift | 13 +++++++ .../Pairing/LibreLoopCGMManager+Pairing.swift | 28 ++++++++++++- LibreLoop/Sensor/LibreLoopGlucoseSample.swift | 4 +- .../LibreLoopCGMManagerStateTests.swift | 39 +++++++++++++++++++ .../Views/LibreLoopSampleDetailView.swift | 10 +++-- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index 1c5f42f..9521936 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -294,6 +294,7 @@ public final class LibreLoopCGMManager: CGMManager { } func recordSample(_ sample: LibreLoopGlucoseSample) { + recentSamples.removeAll { $0.lifeCount == sample.lifeCount && $0.source != .realtime } recentSamples.insert(sample, at: 0) if recentSamples.count > Self.recentSamplesCap { recentSamples.removeLast(recentSamples.count - Self.recentSamplesCap) @@ -306,6 +307,18 @@ public final class LibreLoopCGMManager: CGMManager { setState(updated) } + /// Adds backfilled readings to Recent Readings, in lifeCount order, without + /// touching the Last Reading card. + func recordBackfillSamples(_ samples: [LibreLoopGlucoseSample]) { + let known = Set(recentSamples.map(\.lifeCount)) + let added = samples.filter { !known.contains($0.lifeCount) } + guard !added.isEmpty else { return } + recentSamples = Array((recentSamples + added).sorted { $0.lifeCount > $1.lifeCount }.prefix(Self.recentSamplesCap)) + var updated = state + updated.recentSamples = Array(recentSamples.prefix(LibreLoopCGMManagerState.recentSamplesPersistenceCap)) + setState(updated) + } + /// Update the most recently recorded sample with its forwarding /// outcome (sent vs throttled vs non-actionable). Matched by /// lifeCount, which is unique within a sensor session. No-op if the diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index cd8cbbc..59b4f7f 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -674,8 +674,9 @@ extension LibreLoopCGMManager { // (historical and clinical can overlap, and they may not even agree // on values for the same lifeCount since they're different sensor // pipelines). - let realtimeLifeCounts = Set(recentSamples.map { $0.lifeCount }) + let realtimeLifeCounts = Set(recentSamples.filter { $0.source == .realtime }.map { $0.lifeCount }) var newSamples: [NewGlucoseSample] = [] + var displaySamples: [LibreLoopGlucoseSample] = [] var droppedRealtimeDup = 0 var droppedBackfillDup = 0 for sample in page.samples { @@ -706,8 +707,20 @@ extension LibreLoopCGMManager { device: device, isBackfill: true )) + displaySamples.append(LibreLoopGlucoseSample( + date: date, + valueMgDL: Double(mgdl), + trend: .notDetermined, + rateOfChangeMgDLPerMinute: nil, + lifeCount: sample.lifeCount, + sensorTemperatureRaw: 0, + isActionable: true, + source: .historicalBackfill, + wasForwarded: true + )) backfillForwardedLifeCounts.insert(sample.lifeCount) } + recordBackfillSamples(displaySamples) if !newSamples.isEmpty { delegateQueue?.async { [weak self] in guard let self else { return } @@ -750,7 +763,7 @@ extension LibreLoopCGMManager { return } let serial = state.sensorSerial ?? "unknown" - let realtimeLifeCounts = Set(recentSamples.map { $0.lifeCount }) + let realtimeLifeCounts = Set(recentSamples.filter { $0.source == .realtime }.map { $0.lifeCount }) // Forward only the per-minute `currentGlucoseMgDL` at // `record.lifeCount`. Per upstream RoundWhiteDiscKit guidance: @@ -811,6 +824,17 @@ extension LibreLoopCGMManager { isBackfill: true ) backfillForwardedLifeCounts.insert(lifeCount) + recordBackfillSamples([LibreLoopGlucoseSample( + date: date, + valueMgDL: Double(mgdl), + trend: .notDetermined, + rateOfChangeMgDLPerMinute: nil, + lifeCount: lifeCount, + sensorTemperatureRaw: 0, + isActionable: true, + source: .clinicalBackfill, + wasForwarded: true + )]) llog("forwarded clinical \(kind) sample to Loop: \(mgdl) mg/dL lifeCount=\(lifeCount) sampleDate=\(date.timeIntervalSince1970)") delegateQueue?.async { [weak self] in guard let self else { return } diff --git a/LibreLoop/Sensor/LibreLoopGlucoseSample.swift b/LibreLoop/Sensor/LibreLoopGlucoseSample.swift index 8600d0e..5f5d3fe 100644 --- a/LibreLoop/Sensor/LibreLoopGlucoseSample.swift +++ b/LibreLoop/Sensor/LibreLoopGlucoseSample.swift @@ -60,9 +60,7 @@ public struct LibreLoopGlucoseSample: Equatable, Sendable { /// reading actionable (e.g. "Warming up: 18 min remaining", /// "Sensor condition: invalid"). nil when the reading IS actionable. public let qualityIssue: String? - /// How this sample reached LibreLoop. Currently `recentSamples` only - /// contains `.realtime` entries; the field exists so backfill samples - /// can be surfaced in the same list later without a schema bump. + /// How this sample reached LibreLoop. public let source: Source /// True iff this sample was actually handed off to Loop's CGMManager /// delegate. Updated in place after the throttle/actionability gate diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift index e83700a..ce363ab 100644 --- a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift +++ b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift @@ -221,3 +221,42 @@ final class LibreLoopSensorStateEventTests: XCTestCase { XCTAssertEqual(restored?.sensorEndRecorded, true) } } + +final class LibreLoopRecentReadingsTests: XCTestCase { + private let activatedAt = Date(timeIntervalSince1970: 1_700_000_000) + + private func sample(_ lifeCount: UInt16, _ mgdl: Double, source: LibreLoopGlucoseSample.Source = .realtime) -> LibreLoopGlucoseSample { + LibreLoopGlucoseSample( + date: activatedAt.addingTimeInterval(TimeInterval(lifeCount) * 60), + valueMgDL: mgdl, + trend: source == .realtime ? .stable : .notDetermined, + rateOfChangeMgDLPerMinute: nil, + lifeCount: lifeCount, + sensorTemperatureRaw: 0, + isActionable: true, + source: source + ) + } + + func testBackfillFillsTheGapInLifeCountOrder() { + let manager = LibreLoopCGMManager() + manager.recordSample(sample(100, 90)) + manager.recordSample(sample(130, 92)) + + manager.recordBackfillSamples([sample(110, 93, source: .historicalBackfill), sample(100, 91, source: .historicalBackfill)]) + manager.recordBackfillSamples([sample(120, 94, source: .clinicalBackfill)]) + + XCTAssertEqual(manager.recentSamples.map(\.lifeCount), [130, 120, 110, 100]) + XCTAssertEqual(manager.recentSamples.map(\.source), [.realtime, .clinicalBackfill, .historicalBackfill, .realtime]) + XCTAssertEqual(manager.latestSample?.lifeCount, 130) + } + + func testRealtimeReplacesBackfillForTheSameMinute() { + let manager = LibreLoopCGMManager() + manager.recordBackfillSamples([sample(140, 95, source: .clinicalBackfill)]) + manager.recordSample(sample(140, 96)) + + XCTAssertEqual(manager.recentSamples.map(\.lifeCount), [140]) + XCTAssertEqual(manager.recentSamples.first?.source, .realtime) + } +} diff --git a/LibreLoopUI/Views/LibreLoopSampleDetailView.swift b/LibreLoopUI/Views/LibreLoopSampleDetailView.swift index 12c05d3..7be64bb 100644 --- a/LibreLoopUI/Views/LibreLoopSampleDetailView.swift +++ b/LibreLoopUI/Views/LibreLoopSampleDetailView.swift @@ -62,10 +62,12 @@ struct LibreLoopSampleDetailView: View { .monospacedDigit() } - Section(LocalizedString("Sensor diagnostics", comment: "Sample detail section: diagnostics")) { - LabeledContent(LocalizedString("Temperature (raw)", comment: "Sample detail: raw sensor temperature"), value: "0x\(String(sample.sensorTemperatureRaw, radix: 16, uppercase: true)) (\(sample.sensorTemperatureRaw))") - .monospaced() - .font(.footnote) + if sample.source == .realtime { + Section(LocalizedString("Sensor diagnostics", comment: "Sample detail section: diagnostics")) { + LabeledContent(LocalizedString("Temperature (raw)", comment: "Sample detail: raw sensor temperature"), value: "0x\(String(sample.sensorTemperatureRaw, radix: 16, uppercase: true)) (\(sample.sensorTemperatureRaw))") + .monospaced() + .font(.footnote) + } } } .navigationTitle(LocalizedString("Sample detail", comment: "Sample detail screen title")) From 31772a45b44e428c86e96d8fa2b223dcc956c14f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 18 Oct 2026 15:41:12 -0500 Subject: [PATCH 2/2] Mark backfilled rows in Recent Readings Backfilled readings have no trend, so their rows showed a dash in the trend column and looked like realtime readings with an undetermined trend. Show a clock icon there instead, labelled "Backfilled" for VoiceOver. --- LibreLoopUI/Localizable.xcstrings | 4 ++++ LibreLoopUI/Views/LibreLoopSettingsView.swift | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/LibreLoopUI/Localizable.xcstrings b/LibreLoopUI/Localizable.xcstrings index 4e0073b..94f5eed 100644 --- a/LibreLoopUI/Localizable.xcstrings +++ b/LibreLoopUI/Localizable.xcstrings @@ -67,6 +67,10 @@ "comment" : "BLE PIN row label", "extractionState" : "manual" }, + "Backfilled" : { + "comment" : "Recent readings: backfilled reading marker", + "extractionState" : "manual" + }, "Bluetooth" : { "comment" : "Bluetooth address row label\n Bluetooth connection row label", "extractionState" : "manual" diff --git a/LibreLoopUI/Views/LibreLoopSettingsView.swift b/LibreLoopUI/Views/LibreLoopSettingsView.swift index 6492008..f3d0639 100644 --- a/LibreLoopUI/Views/LibreLoopSettingsView.swift +++ b/LibreLoopUI/Views/LibreLoopSettingsView.swift @@ -606,8 +606,14 @@ struct LibreLoopReadingRow: View { .imageScale(.small) } Spacer() - Image(systemName: trendSymbol) - .foregroundStyle(.secondary) + if sample.source == .realtime { + Image(systemName: trendSymbol) + .foregroundStyle(.secondary) + } else { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + .accessibilityLabel(LocalizedString("Backfilled", comment: "Recent readings: backfilled reading marker")) + } } .font(.subheadline) .opacity(sample.isActionable ? 1.0 : 0.7)