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/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/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")) 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)