diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 9b451ac6a..7b9854e8b 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -112,6 +112,7 @@ final class BGChartModel: ObservableObject { @Published var bg: [BGPoint] = [] @Published var bgRuns: [BGRun] = [] + @Published var smoothedBg: [BGPoint] = [] @Published var yesterday: [BGPoint] = [] @Published var prediction: [BGPoint] = [] @Published var ztPrediction: [BGPoint] = [] @@ -171,9 +172,11 @@ final class BGChartModel: ObservableObject { private(set) var generation: Int = 0 private var rebuildScheduled = false + private var smoothedBgHistory: [SmoothedBgPoint] = [] @Published var showLines: Bool = true @Published var showDots: Bool = true + @Published var showSmoothedBg: Bool = false @Published var showDIA: Bool = true @Published var show30Min: Bool = false @Published var show90Min: Bool = false @@ -215,6 +218,14 @@ final class BGChartModel: ObservableObject { pillTimeFormatter.string(from: date) } + func smoothedBgValue(near date: Date, tolerance: TimeInterval = 150) -> Double? { + SmoothedBgSeries.nearestValue( + in: smoothedBgHistory, + to: date.timeIntervalSince1970, + tolerance: tolerance + ) + } + /// Nightscout remote-command error notes embed a JSON payload after /// the human-readable message ("Error text {\"bolus-entry\": 1.5, ...}"). /// Returns the message plus a compact summary of the payload, or nil when @@ -406,6 +417,10 @@ final class BGChartModel: ObservableObject { showLines = Storage.shared.showLines.value showDots = Storage.shared.showDots.value + showSmoothedBg = Storage.shared.displaySmoothedBG.value + && IsNightscoutEnabled() + && Storage.shared.device.value != "Loop" + && !vc.smoothedBgData.isEmpty showDIA = Storage.shared.showDIALines.value show30Min = Storage.shared.show30MinLine.value show90Min = Storage.shared.show90MinLine.value @@ -433,6 +448,27 @@ final class BGChartModel: ObservableObject { bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) } bgRuns = Self.makeRuns(bg) + if showSmoothedBg, + let firstBgTime = vc.bgData.first?.date, + let lastBgTime = vc.bgData.last?.date + { + smoothedBgHistory = vc.smoothedBgData + smoothedBg = SmoothedBgSeries.chartPoints( + from: smoothedBgHistory, + startingAt: firstBgTime, + endingAt: lastBgTime + 150 + ).map { + BGPoint( + date: Date(timeIntervalSince1970: $0.time), + value: min(max($0.bgMgdl, Double(minDisplay)), Double(maxDisplay)), + color: .cyan + ) + } + } else { + smoothedBgHistory = [] + smoothedBg = [] + } + // Yesterday comparison overlay (#665): already +24h shifted, dimmed gray, no dots. if Storage.shared.showYesterdayLine.value { yesterday = vc.yesterdayBGData.map { diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 9098bd123..ad04f1cce 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -702,7 +702,14 @@ private struct MainBGChart: View { /// Pill entry for a BG reading. Shared by the scrub lookup and the tap hit test. private func bgPillText(for point: BGChartModel.BGPoint) -> String { - "BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))\n\(model.pillTimeString(for: point.date))" + let rawBg = Localizer.toDisplayUnits(String(Int(point.value))) + let time = model.pillTimeString(for: point.date) + if model.showSmoothedBg, + let smoothed = model.smoothedBgValue(near: point.date) + { + return "✨ \(Localizer.toDisplayUnits(String(smoothed))) ✨\n\(rawBg)\n\(time)" + } + return "BG\n\(rawBg)\n\(time)" } private func bandPillTexts(at date: Date) -> [String] { @@ -1089,6 +1096,7 @@ private struct BGChartCanvas: View, Equatable { yesterdayMarks } bgLineMarks + smoothedBgMarks bgPointsMark predictionLineMark predictionVariantMarks @@ -1278,7 +1286,7 @@ private struct BGChartCanvas: View, Equatable { @ChartContentBuilder private var bgLineMarks: some ChartContent { - if model.showLines { + if model.showLines, isSmall || !model.showSmoothedBg { ForEach(model.bgRuns) { run in if let first = run.points.first, let last = run.points.last, last.date >= windowStart, first.date <= windowEnd @@ -1298,9 +1306,25 @@ private struct BGChartCanvas: View, Equatable { } } + @ChartContentBuilder + private var smoothedBgMarks: some ChartContent { + if model.showSmoothedBg, !isSmall { + ForEach(windowedLine(model.smoothedBg) { $0.date }) { point in + LineMark( + x: .value("time", point.date), + y: .value("bg", point.value), + series: .value("series", "smoothed-bg") + ) + .foregroundStyle(.cyan) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + .interpolationMethod(.linear) + } + } + } + @ChartContentBuilder private var bgPointsMark: some ChartContent { - if model.showDots { + if model.showDots || (model.showSmoothedBg && !isSmall) { ForEach(windowed(model.bg) { $0.date }) { pt in PointMark( x: .value("time", pt.date), diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index a568746c7..b92604829 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -152,6 +152,19 @@ extension MainViewController { let latestReading = data[0] let sensorTimestamp = latestReading.date + + // If this is a brand-new reading (newer than what we last processed), pull + // devicestatus right away so the smoothed BG for this dot lands on the chart + // alongside the dot itself, not on the next 5-min devicestatus poll. + let previouslyProcessedBgTime = Storage.shared.lastBgReadingTimeSeconds.value ?? 0 + if Storage.shared.displaySmoothedBG.value, + Storage.shared.device.value != "Loop", + sensorTimestamp > previouslyProcessedBgTime, + IsNightscoutEnabled() + { + // Tiny buffer so the loop has a moment to write its devicestatus record. + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(1)) + } let now = dateTimeUtils.getNowTimeIntervalUTC() // secondsAgo is how old the newest reading is let secondsAgo = now - sensorTimestamp diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 89126e6b1..bf2eff237 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -67,7 +67,7 @@ extension MainViewController { func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) { let previousIOBText = Observable.shared.iobText.value let previousDeviceWasLoop = Storage.shared.device.value == "Loop" - infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd]) + infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd, .smoothedBg]) // For Loop, clear the current override here - For Trio, it is handled using treatments if Storage.shared.device.value == "Loop" { @@ -200,8 +200,11 @@ extension MainViewController { } // OpenAPS - handle new data + var processedOpenAPS = false + var parsedOpenAPSTimestamp = false if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String: AnyObject]? { - DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord) + processedOpenAPS = true + parsedOpenAPSTimestamp = DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord) } // If the active looping system flipped (Loop ⇄ Trio/OpenAPS), drop the previous @@ -219,7 +222,35 @@ extension MainViewController { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0) + // Trio can upload a thin devicestatus record between full loop records. + // While the newest BG is fresh, poll quickly if that record did not + // repopulate the loop timestamp or if its matching smoothed value has + // not arrived yet. Keep this OpenAPS-only so Loop users never inherit + // the smoothing retry cadence. + let latestBgTime = bgData.last?.date ?? Storage.shared.lastBgReadingTimeSeconds.value + let latestBgAge = latestBgTime.map { max(0, now - $0) } ?? .infinity + let smoothingRetryEnabled = processedOpenAPS && Storage.shared.displaySmoothedBG.value + let recordIsSparse = smoothingRetryEnabled && !parsedOpenAPSTimestamp + let needsSmoothedBgRetry: Bool = { + guard smoothingRetryEnabled, + let latestBg = bgData.last, + latestBgAge < 300 + else { return false } + return smoothedBg(near: latestBg.date) == nil + }() + let needsSparseRecordRetry = recordIsSparse && latestBgAge < 300 + let needsRetry = needsSmoothedBgRetry || needsSparseRecordRetry + let retryDelay: TimeInterval = latestBgAge < 60 ? 3 : 15 + DispatchQueue.main.async { + if needsRetry { + TaskScheduler.shared.rescheduleTask( + id: .deviceStatus, + to: Date().addingTimeInterval(retryDelay) + ) + return + } + var interval: Double if secondsAgo >= (20 * 60) { interval = 5 * 60 @@ -249,6 +280,13 @@ extension MainViewController { // Mark device status as loaded for initial loading state markDataLoaded("deviceStatus") + if processedOpenAPS, + Storage.shared.displaySmoothedBG.value, + !hasFetchedSmoothedBgHistory + { + webLoadNSSmoothedBgHistory() + } + if Storage.shared.contactEnabled.value, Storage.shared.contactIOB.value != .off, Observable.shared.iobText.value != previousIOBText { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 7dfdb4cdb..6ef452db9 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -5,23 +5,42 @@ import Foundation import HealthKit extension MainViewController { - func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) { + func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) -> Bool { Storage.shared.device.value = lastDeviceStatus?["device"] as? String ?? "" if lastLoopRecord["failureReason"] != nil { Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" + return false } else { - guard let enactedOrSuggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? lastLoopRecord["enacted"] as? [String: AnyObject] else { + // Suggested is the current loop's view, while enacted can carry + // fields such as TDD that are omitted when no new action was needed. + // Merge both and prefer suggested values on collisions. + let suggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? [:] + let enacted = lastLoopRecord["enacted"] as? [String: AnyObject] ?? [:] + guard !suggested.isEmpty || !enacted.isEmpty else { Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" - return + return false } + let enactedOrSuggested = enacted.merging(suggested) { _, suggestedValue in suggestedValue } var updatedTime: TimeInterval? - if let timestamp = enactedOrSuggested["deliverAt"] as? String ?? enactedOrSuggested["timestamp"] as? String, - let parsedTime = formatter.date(from: timestamp)?.timeIntervalSince1970 - { + // Prefer the current suggestion, then the outer Nightscout record, + // and finally the potentially older enacted timestamp. parseDate + // tolerates fractional seconds and the common trailing Z. + let timestampCandidates: [String?] = [ + suggested["deliverAt"] as? String, + suggested["timestamp"] as? String, + lastDeviceStatus?["created_at"] as? String, + enacted["deliverAt"] as? String, + enacted["timestamp"] as? String, + ] + let parsedTime = timestampCandidates + .compactMap { $0.flatMap { SmoothedBgSeries.parseDate($0) } } + .first? + .timeIntervalSince1970 + if let parsedTime { updatedTime = parsedTime let formattedTime = Localizer.formatTimestampToLocalString(parsedTime) infoManager.updateInfoData(type: .updated, value: formattedTime) @@ -121,6 +140,39 @@ extension MainViewController { Observable.shared.deviceRecBolus.value = nil } + let smoothedBgPoint: SmoothedBgPoint? = { + if let bg = suggested["bg"] as? Double { + return SmoothedBgSeries.point( + bg: bg, + timestampCandidates: [ + suggested["deliverAt"] as? String, + suggested["timestamp"] as? String, + lastDeviceStatus?["created_at"] as? String, + enacted["deliverAt"] as? String, + enacted["timestamp"] as? String, + ] + ) + } + if let bg = enacted["bg"] as? Double { + return SmoothedBgSeries.point( + bg: bg, + timestampCandidates: [ + enacted["deliverAt"] as? String, + enacted["timestamp"] as? String, + lastDeviceStatus?["created_at"] as? String, + ] + ) + } + return nil + }() + if Storage.shared.displaySmoothedBG.value, let smoothedBgPoint { + appendSmoothedBgPoint(time: smoothedBgPoint.time, bgMgdl: smoothedBgPoint.bgMgdl) + infoManager.updateInfoData( + type: .smoothedBg, + value: Localizer.toDisplayUnits(String(smoothedBgPoint.bgMgdl)) + ) + } + // Eventual BG if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) @@ -241,6 +293,7 @@ extension MainViewController { // Live Activity storage Storage.shared.lastIOB.value = latestIOB?.value Storage.shared.lastCOB.value = latestCOB?.value + return updatedTime != nil } } } diff --git a/LoopFollow/Controllers/Nightscout/SmoothedBgHistory.swift b/LoopFollow/Controllers/Nightscout/SmoothedBgHistory.swift new file mode 100644 index 000000000..9e1f8e8e2 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/SmoothedBgHistory.swift @@ -0,0 +1,291 @@ +// LoopFollow +// SmoothedBgHistory.swift + +import Foundation + +struct SmoothedBgPoint: Equatable, Sendable { + let time: TimeInterval + let bgMgdl: Double +} + +enum SmoothedBgSeries { + private static let fractionalISO8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private static let timezoneLessFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + static func parseDate(_ rawString: String) -> Date? { + if let date = fractionalISO8601Formatter.date(from: rawString) + ?? iso8601Formatter.date(from: rawString) + { + return date + } + + let withoutFraction = rawString.replacingOccurrences( + of: "\\.\\d+$", + with: "", + options: .regularExpression + ) + return timezoneLessFormatter.date(from: withoutFraction) + } + + static func point( + bg: Double, + timestampCandidates: [String?] + ) -> SmoothedBgPoint? { + for candidate in timestampCandidates { + guard let candidate, let date = parseDate(candidate) else { continue } + return SmoothedBgPoint(time: date.timeIntervalSince1970, bgMgdl: bg) + } + return nil + } + + static func nearestValue( + in points: [SmoothedBgPoint], + to time: TimeInterval, + tolerance: TimeInterval = 150 + ) -> Double? { + var best: SmoothedBgPoint? + var bestDifference = tolerance + + for point in points { + let difference = abs(point.time - time) + if difference <= bestDifference { + best = point + bestDifference = difference + } + if point.time - time > tolerance { break } + } + + return best?.bgMgdl + } + + static func chartPoints( + from points: [SmoothedBgPoint], + startingAt start: TimeInterval, + endingAt end: TimeInterval, + minimumSpacing: TimeInterval = 240 + ) -> [SmoothedBgPoint] { + var result: [SmoothedBgPoint] = [] + var lastKeptTime = -TimeInterval.infinity + + for point in points.sorted(by: { $0.time < $1.time }) + where point.time >= start && point.time <= end + { + guard point.time - lastKeptTime >= minimumSpacing else { continue } + result.append(point) + lastKeptTime = point.time + } + + return result + } +} + +/// Decodable view of a single Nightscout devicestatus record, narrowed to just the +/// fields needed to extract OpenAPS/Trio's smoothed BG. Unrecognized JSON keys are +/// ignored by JSONDecoder, so the full devicestatus payload is parsed cheaply — +/// no nested predictions / IOB / COB tree is materialized. +struct DeviceStatusBgRecord: Decodable, Sendable { + let createdAt: String? + let openaps: OpenAPSBlock? + + enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case openaps + } + + struct OpenAPSBlock: Decodable, Sendable { + let suggested: BgInner? + let enacted: BgInner? + } + + struct BgInner: Decodable, Sendable { + let bg: Double? + let timestamp: String? + let deliverAt: String? + } + + func point() -> SmoothedBgPoint? { + if let suggested = openaps?.suggested, let bg = suggested.bg { + return point( + bg: bg, + timestampCandidates: [ + suggested.deliverAt, + suggested.timestamp, + createdAt, + openaps?.enacted?.deliverAt, + openaps?.enacted?.timestamp, + ] + ) + } + + if let enacted = openaps?.enacted, let bg = enacted.bg { + return point( + bg: bg, + timestampCandidates: [enacted.deliverAt, enacted.timestamp, createdAt] + ) + } + + return nil + } + + private func point(bg: Double, timestampCandidates: [String?]) -> SmoothedBgPoint? { + SmoothedBgSeries.point(bg: bg, timestampCandidates: timestampCandidates) + } +} + +extension MainViewController { + /// Fetches OpenAPS/Trio devicestatus records over the configured graph range and + /// extracts each loop run's smoothed BG, so the chart-tap popup can show the + /// smoothed value next to every glucose dot. Mirrors the BG-data fetch path: + /// typed `Decodable` + `count` + `find[date][$gte]` + `executeRequest`. + func webLoadNSSmoothedBgHistory() { + guard Storage.shared.displaySmoothedBG.value else { return } + guard IsNightscoutEnabled() else { return } + guard Storage.shared.device.value != "Loop" else { return } + + let requestGeneration = smoothedBgFetchGeneration + let requestURL = Storage.shared.url.value + let requestToken = Storage.shared.token.value + let requestDevice = Storage.shared.device.value + + // Mark as fetched up-front so the gating check in DeviceStatus.swift doesn't + // re-enter while this request is in flight. Reset on failure below. + hasFetchedSmoothedBgHistory = true + lastSmoothedBgBulkRefreshAt = Date() + + let days = max(1, Storage.shared.downloadDays.value) + let count = days * 24 * 12 + 24 + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(abbreviation: "UTC") + let startDate = Date().addingTimeInterval(-Double(days) * 86400) + + let parameters: [String: String] = [ + "count": "\(count)", + "find[created_at][$gte]": formatter.string(from: startDate), + ] + + NightscoutUtils.executeRequest(eventType: .deviceStatus, parameters: parameters) { [weak self] (result: Result<[DeviceStatusBgRecord], Error>) in + switch result { + case let .success(records): + // executeRequest delivers successful decodes on the main queue. + // Parse the potentially four-day history off-main, reusing the + // formatters above instead of constructing one per record. + DispatchQueue.global(qos: .userInitiated).async { + var parsedKeys = Set() + var parsedPoints: [SmoothedBgPoint] = [] + parsedPoints.reserveCapacity(records.count) + for record in records { + guard let point = record.point() else { continue } + // Dedup by integer-second to collapse near-duplicate enacted/suggested rows. + if parsedKeys.insert(Int(point.time)).inserted { + parsedPoints.append(point) + } + } + + DispatchQueue.main.async { + guard let self else { return } + guard self.smoothedBgFetchGeneration == requestGeneration, + Storage.shared.displaySmoothedBG.value, + Storage.shared.url.value == requestURL, + Storage.shared.token.value == requestToken, + Storage.shared.device.value == requestDevice + else { return } + var seen = parsedKeys + var points = parsedPoints + // Merge with anything appendSmoothedBgPoint added while the fetch was in flight. + for existing in self.smoothedBgData { + if seen.insert(Int(existing.time)).inserted { + points.append(existing) + } + } + points.sort { $0.time < $1.time } + self.smoothedBgData = points + self.updateBGGraph() + } + } + + case let .failure(error): + LogManager.shared.log(category: .deviceStatus, message: "Smoothed BG history fetch failed: \(error.localizedDescription)", limitIdentifier: "Smoothed BG history fetch failed") + DispatchQueue.main.async { + // Allow retry on the next devicestatus cycle. + guard let self, self.smoothedBgFetchGeneration == requestGeneration else { return } + self.hasFetchedSmoothedBgHistory = false + } + } + } + } + + /// Invalidates both the visible cache and any in-flight bulk request. Call when + /// the graph range or Nightscout identity changes, or when smoothing is disabled. + func invalidateSmoothedBgCache() { + smoothedBgFetchGeneration &+= 1 + smoothedBgData = [] + hasFetchedSmoothedBgHistory = false + lastSmoothedBgBulkRefreshAt = nil + infoManager.clearInfoData(type: .smoothedBg) + updateBGGraph() + } + + /// Merge a single freshly-parsed point into the in-memory history. Called after + /// each devicestatus refresh so the latest reading always has a match without a + /// new bulk fetch. + func appendSmoothedBgPoint(time: TimeInterval, bgMgdl: Double) { + guard Storage.shared.displaySmoothedBG.value else { return } + let key = Int(time) + if smoothedBgData.contains(where: { Int($0.time) == key }) { return } + + let previousLatestTime = smoothedBgData.last?.time + smoothedBgData.append(SmoothedBgPoint(time: time, bgMgdl: bgMgdl)) + smoothedBgData.sort { $0.time < $1.time } + + // Drop entries older than the configured graph range to bound memory. + let cutoff = Date().timeIntervalSince1970 - Double(max(1, Storage.shared.downloadDays.value)) * 86400 + if let firstKept = smoothedBgData.firstIndex(where: { $0.time >= cutoff }), firstKept > 0 { + smoothedBgData.removeFirst(firstKept) + } + + // Refresh the chart so the dot for this loop run picks up the smoothed + // value immediately — without waiting for the next BG fetch cycle. + updateBGGraph() + + // Gap detection: if the new point is far ahead of the previous latest, + // we likely missed loop runs (Trio offline, network glitch, etc.). Trigger + // a debounced bulk refresh so older dots can backfill their smoothed values + // without needing a force-close + reopen. + if let prev = previousLatestTime, time - prev > 360 { + considerSmoothedBgGapRefresh() + } + } + + private func considerSmoothedBgGapRefresh() { + let lastAge = lastSmoothedBgBulkRefreshAt.map { -$0.timeIntervalSinceNow } ?? .infinity + guard lastAge >= 120 else { return } // debounce: max once per 2 min + hasFetchedSmoothedBgHistory = false + webLoadNSSmoothedBgHistory() + } + + /// Look up the smoothed BG closest to the given timestamp. Returns nil if no + /// recorded loop run is within the tolerance window. + func smoothedBg(near time: TimeInterval, tolerance: TimeInterval = 150) -> Double? { + SmoothedBgSeries.nearestValue(in: smoothedBgData, to: time, tolerance: tolerance) + } +} diff --git a/LoopFollow/InfoTable/InfoType.swift b/LoopFollow/InfoTable/InfoType.swift index 173ca416e..1b971756e 100644 --- a/LoopFollow/InfoTable/InfoType.swift +++ b/LoopFollow/InfoTable/InfoType.swift @@ -4,7 +4,7 @@ import Foundation enum InfoType: Int, CaseIterable, Codable { - case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize + case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize, smoothedBg var name: String { switch self { @@ -29,6 +29,7 @@ enum InfoType: Int, CaseIterable, Codable { case .tdd: return "TDD" case .iage: return "IAGE" case .dbSize: return "DB Size" + case .smoothedBg: return "Smoothed BG" } } diff --git a/LoopFollow/Settings/AdvancedSettingsView.swift b/LoopFollow/Settings/AdvancedSettingsView.swift index 9665df882..1d1f27da1 100644 --- a/LoopFollow/Settings/AdvancedSettingsView.swift +++ b/LoopFollow/Settings/AdvancedSettingsView.swift @@ -19,6 +19,8 @@ struct AdvancedSettingsView: View { Stepper(value: $viewModel.bgUpdateDelay, in: 1 ... 30, step: 1) { Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") } + + Toggle("Display Smoothed BG", isOn: $viewModel.displaySmoothedBG) } Section(header: Text("Logging Options")) { diff --git a/LoopFollow/Settings/AdvancedSettingsViewModel.swift b/LoopFollow/Settings/AdvancedSettingsViewModel.swift index 078e62288..c2f81c6bc 100644 --- a/LoopFollow/Settings/AdvancedSettingsViewModel.swift +++ b/LoopFollow/Settings/AdvancedSettingsViewModel.swift @@ -50,6 +50,12 @@ class AdvancedSettingsViewModel: ObservableObject { } } + @Published var displaySmoothedBG: Bool { + didSet { + Storage.shared.displaySmoothedBG.value = displaySmoothedBG + } + } + @Published var debugLogLevel: Bool { didSet { Storage.shared.debugLogLevel.value = debugLogLevel @@ -64,6 +70,7 @@ class AdvancedSettingsViewModel: ObservableObject { graphCarbs = Storage.shared.graphCarbs.value graphOtherTreatments = Storage.shared.graphOtherTreatments.value bgUpdateDelay = Storage.shared.bgUpdateDelay.value + displaySmoothedBG = Storage.shared.displaySmoothedBG.value debugLogLevel = Storage.shared.debugLogLevel.value } } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 07d9d8d91..e2d066b56 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -6,6 +6,9 @@ import SwiftUI struct GraphSettingsView: View { @ObservedObject private var showDots = Storage.shared.showDots @ObservedObject private var showLines = Storage.shared.showLines + @ObservedObject private var displaySmoothedBG = Storage.shared.displaySmoothedBG + @ObservedObject private var nightscoutURL = Storage.shared.url + @ObservedObject private var device = Storage.shared.device @ObservedObject private var showValues = Storage.shared.showValues @ObservedObject private var showAbsorption = Storage.shared.showAbsorption @ObservedObject private var showDIALines = Storage.shared.showDIALines @@ -27,12 +30,16 @@ struct GraphSettingsView: View { var body: some View { Form { // ── Graph Display ──────────────────────────────────────────── - Section("Graph Display") { + Section(header: Text("Graph Display"), footer: smoothingFooter) { Toggle("Display Dots", isOn: $showDots.value) .onChange(of: showDots.value) { _ in markDirty() } + .disabled(smoothingActive) + .foregroundColor(smoothingActive ? .secondary : .primary) Toggle("Display Lines", isOn: $showLines.value) .onChange(of: showLines.value) { _ in markDirty() } + .disabled(smoothingActive) + .foregroundColor(smoothingActive ? .secondary : .primary) if nightscoutEnabled { Toggle("Show DIA Lines", isOn: $showDIALines.value) @@ -140,4 +147,17 @@ struct GraphSettingsView: View { private func markDirty() { Observable.shared.chartSettingsChanged.value = true } + + @ViewBuilder + private var smoothingFooter: some View { + if smoothingActive { + Text("Display Dots and Display Lines are managed automatically while Display Smoothed BG is on (CGM dots shown, cyan smoothing line replaces the connecting line).") + } else { + EmptyView() + } + } + + private var smoothingActive: Bool { + displaySmoothedBG.value && !nightscoutURL.value.isEmpty && device.value != "Loop" + } } diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index 34022aff0..4be4e2ccf 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -80,12 +80,20 @@ extension Storage { let sort = legacySort.value let visible = legacyVisible.value + // The private smoothing branch used raw value 20 for Smoothed BG before + // upstream added DB Size at 20. A persisted smoothing preference proves + // which lineage produced that legacy index; remap it while leaving + // upstream DB Size users untouched. + let remapLegacySmoothedBG = displaySmoothedBG.exists var items: [InfoDisplayItem] = [] var seen = Set() // Honor the saved order and per-index visibility. for index in sort { - guard let type = InfoType(rawValue: index), seen.insert(index).inserted else { continue } + let type = remapLegacySmoothedBG && index == 20 + ? InfoType.smoothedBg + : InfoType(rawValue: index) + guard let type, seen.insert(type.rawValue).inserted else { continue } let isVisible = index < visible.count ? visible[index] : type.defaultVisible items.append(InfoDisplayItem(type: type, isVisible: isVisible, coloring: InfoColoring())) } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..f8077470c 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -169,6 +169,7 @@ class Storage { var graphBolus = StorageValue(key: "graphBolus", defaultValue: true) var graphCarbs = StorageValue(key: "graphCarbs", defaultValue: true) var bgUpdateDelay = StorageValue(key: "bgUpdateDelay", defaultValue: 10) + var displaySmoothedBG = StorageValue(key: "displaySmoothedBG", defaultValue: false) // MARK: - Insert times (sensor / pump) --------------------------------------- diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ed1ce880f..13245f809 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -22,6 +22,12 @@ private struct APNSCredentialSnapshot: Equatable { let lfKeyId: String } +private struct SmoothedBgConfiguration: Equatable { + let url: String + let token: String + let device: String +} + class MainViewController: UIViewController, UNUserNotificationCenterDelegate { /// The single, long-lived MainViewController that owns the app's data /// pipeline (scheduleAllTasks). Held strongly so it stays alive — and the @@ -93,6 +99,10 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var uamPredictionData: [ShareGlucoseData] = [] var openAPSPredBGs: [String: [Double]]? var openAPSPredUpdatedTime: TimeInterval? + var smoothedBgData: [SmoothedBgPoint] = [] + var hasFetchedSmoothedBgHistory: Bool = false + var lastSmoothedBgBulkRefreshAt: Date? + var smoothedBgFetchGeneration: UInt = 0 var bgCheckData: [ShareGlucoseData] = [] var suspendGraphData: [DataStructs.timestampOnlyStruct] = [] var resumeGraphData: [DataStructs.timestampOnlyStruct] = [] @@ -257,6 +267,68 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } .store(in: &cancellables) + // Refetch the smoothed-BG history when the graph day-range setting changes, + // so the popup history covers the newly visible window. The fetch itself + // bails when the feature toggle is off, so this is cheap when unused. + Storage.shared.downloadDays.$value + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard Storage.shared.displaySmoothedBG.value else { return } + self.invalidateSmoothedBgCache() + self.webLoadNSSmoothedBgHistory() + } + .store(in: &cancellables) + + // React to the smoothed-BG toggle: fetch on ON, drop the cached history on OFF + // and refresh the chart so popups stop showing smoothed values immediately. + Storage.shared.displaySmoothedBG.$value + .dropFirst() + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] enabled in + guard let self = self else { return } + if enabled { + self.invalidateSmoothedBgCache() + self.webLoadNSSmoothedBgHistory() + } else { + self.invalidateSmoothedBgCache() + } + } + .store(in: &cancellables) + + // Never carry smoothed history across Nightscout accounts or looping + // systems. The generation token also makes stale in-flight responses inert. + let smoothedBgConfigurationChanges = Publishers.CombineLatest3( + Storage.shared.url.$value, + Storage.shared.token.$value, + Storage.shared.device.$value + ) + .map { url, token, device in + SmoothedBgConfiguration(url: url, token: token, device: device) + } + .dropFirst() + .removeDuplicates() + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + + smoothedBgConfigurationChanges + .sink { [weak self] _ in + self?.invalidateSmoothedBgCache() + } + .store(in: &cancellables) + + smoothedBgConfigurationChanges + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + if Storage.shared.displaySmoothedBG.value { + self.webLoadNSSmoothedBgHistory() + } + } + .store(in: &cancellables) + // Update appearance when setting changes Storage.shared.appearanceMode.$value .receive(on: DispatchQueue.main) diff --git a/Tests/SmoothedBgHistoryTests.swift b/Tests/SmoothedBgHistoryTests.swift new file mode 100644 index 000000000..baa3cb6a2 --- /dev/null +++ b/Tests/SmoothedBgHistoryTests.swift @@ -0,0 +1,111 @@ +// LoopFollow +// SmoothedBgHistoryTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct SmoothedBgHistoryTests { + @Test("suggested smoothed BG and fractional deliverAt take priority") + func suggestedTakesPriority() throws { + let data = Data( + """ + { + "created_at": "2026-08-11T12:00:03.000Z", + "openaps": { + "suggested": { + "bg": 123.5, + "deliverAt": "2026-08-11T12:00:01.250Z" + }, + "enacted": { + "bg": 111, + "timestamp": "2026-08-11T11:55:00Z" + } + } + } + """.utf8 + ) + + let record = try JSONDecoder().decode(DeviceStatusBgRecord.self, from: data) + let point = try #require(record.point()) + let expectedDate = try #require(SmoothedBgSeries.parseDate("2026-08-11T12:00:01.250Z")) + + #expect(point.bgMgdl == 123.5) + #expect(point.time == expectedDate.timeIntervalSince1970) + } + + @Test("sparse suggested block falls back to enacted BG") + func enactedFallback() throws { + let data = Data( + """ + { + "created_at": "2026-08-11T12:00:03Z", + "openaps": { + "suggested": { "reason": "no temp required" }, + "enacted": { + "bg": 109, + "timestamp": "2026-08-11T11:59:59Z" + } + } + } + """.utf8 + ) + + let record = try JSONDecoder().decode(DeviceStatusBgRecord.self, from: data) + let point = try #require(record.point()) + let expectedDate = try #require(SmoothedBgSeries.parseDate("2026-08-11T11:59:59Z")) + + #expect(point.bgMgdl == 109) + #expect(point.time == expectedDate.timeIntervalSince1970) + } + + @Test("ISO timestamps preserve explicit timezone offsets") + func timezoneOffset() throws { + let offsetPoint = try #require(SmoothedBgSeries.point( + bg: 120, + timestampCandidates: ["2026-08-11T14:00:01.250+02:00"] + )) + let utcPoint = try #require(SmoothedBgSeries.point( + bg: 120, + timestampCandidates: ["2026-08-11T12:00:01.250Z"] + )) + + #expect(offsetPoint.time == utcPoint.time) + } + + @Test("chart series filters treatment-triggered records closer than four minutes") + func chartSpacing() { + let points = [ + SmoothedBgPoint(time: 0, bgMgdl: 100), + SmoothedBgPoint(time: 100, bgMgdl: 101), + SmoothedBgPoint(time: 300, bgMgdl: 102), + SmoothedBgPoint(time: 540, bgMgdl: 103), + SmoothedBgPoint(time: 900, bgMgdl: 104), + ] + + let filtered = SmoothedBgSeries.chartPoints( + from: points, + startingAt: 0, + endingAt: 600 + ) + + #expect(filtered.map(\.time) == [0, 300, 540]) + } + + @Test("nearest lookup observes its tolerance") + func nearestTolerance() { + let points = [ + SmoothedBgPoint(time: 100, bgMgdl: 101), + SmoothedBgPoint(time: 300, bgMgdl: 103), + ] + + #expect(SmoothedBgSeries.nearestValue(in: points, to: 240) == 103) + #expect(SmoothedBgSeries.nearestValue(in: points, to: 500) == nil) + } + + @Test("new info type preserves upstream DB Size raw value") + func infoTypeRawValues() { + #expect(InfoType.dbSize.rawValue == 20) + #expect(InfoType.smoothedBg.rawValue == 21) + } +}