From 5811984792236a11aa17ade0a67994379a840bf3 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:00:07 -0400 Subject: [PATCH 1/3] Add IOB and COB graph history --- LoopFollow/Charts/BGChartModel.swift | 157 +++++- LoopFollow/Charts/BGChartStubs.swift | 4 + LoopFollow/Charts/BGChartView.swift | 494 ++++++++++++++++-- .../Controllers/Nightscout/DeviceStatus.swift | 39 +- .../DeviceStatusMetricHistory.swift | 491 +++++++++++++++++ .../Nightscout/DeviceStatusOpenAPS.swift | 13 +- LoopFollow/Settings/GraphSettingsView.swift | 3 + LoopFollow/Settings/SettingsMenuView.swift | 1 + LoopFollow/Storage/Storage.swift | 1 + LoopFollow/Task/DeviceStatusTask.swift | 1 + .../ViewControllers/MainViewController.swift | 9 + Tests/Charts/BGChartTapCandidateTests.swift | 47 ++ Tests/Charts/OnBoardOverlayScaleTests.swift | 85 +++ ...DeviceStatusMetricHistoryParserTests.swift | 201 +++++++ 14 files changed, 1483 insertions(+), 63 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/DeviceStatusMetricHistory.swift create mode 100644 Tests/Charts/BGChartTapCandidateTests.swift create mode 100644 Tests/Charts/OnBoardOverlayScaleTests.swift create mode 100644 Tests/Nightscout/DeviceStatusMetricHistoryParserTests.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 9b451ac6a..ac3e9fb27 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -110,6 +110,17 @@ final class BGChartModel: ObservableObject { let points: [BGPoint] } + struct OnBoardPoint: Identifiable, Equatable { + let date: Date + let value: Double + var id: TimeInterval { date.timeIntervalSince1970 } + } + + struct OnBoardRun: Identifiable, Equatable { + let id: Int + let points: [OnBoardPoint] + } + @Published var bg: [BGPoint] = [] @Published var bgRuns: [BGRun] = [] @Published var yesterday: [BGPoint] = [] @@ -119,6 +130,15 @@ final class BGChartModel: ObservableObject { @Published var cobPrediction: [BGPoint] = [] @Published var uamPrediction: [BGPoint] = [] + @Published var iobHistory: [OnBoardPoint] = [] + @Published var cobHistory: [OnBoardPoint] = [] + @Published var iobHistoryRuns: [OnBoardRun] = [] + @Published var cobHistoryRuns: [OnBoardRun] = [] + @Published var iobHistoryMaximum: Double = 1 + @Published var cobHistoryMaximum: Double = 1 + @Published var currentIOB: Double? + @Published var currentCOB: Double? + /// Prediction cone band (min/max envelope). Set by updateOpenAPSPredictionDisplay; /// preserved across rebuild() since it has no source array on the view controller. /// The didSet keeps the canvas generation in sync for call sites that assign the @@ -178,6 +198,7 @@ final class BGChartModel: ObservableObject { @Published var show30Min: Bool = false @Published var show90Min: Bool = false @Published var showMidnight: Bool = false + @Published var showIOBCOBHistory: Bool = true @Published var smallGraphTreatments: Bool = true private static let doseFormatter: NumberFormatter = { @@ -287,6 +308,90 @@ final class BGChartModel: ObservableObject { return runs } + static let onBoardLaneFraction = 0.22 + static let onBoardGapInterval: TimeInterval = 12 * 60 + static let onBoardSelectionTolerance: TimeInterval = 7.5 * 60 + static let onBoardCurrentFreshness: TimeInterval = 15 * 60 + + static func onBoardLaneCeiling(maxBG: Double, lowLine: Double) -> Double { + max(0, min(maxBG * onBoardLaneFraction, lowLine * 0.9)) + } + + static func scaledOnBoardValue( + _ value: Double, + maximum: Double, + laneCeiling: Double + ) -> Double { + guard value.isFinite, maximum.isFinite, maximum > 0, laneCeiling > 0 else { + return 0 + } + return min(max(value, 0) / maximum, 1) * laneCeiling + } + + static func onBoardMaximum(for points: [OnBoardPoint]) -> Double { + max(points.lazy.map(\.value).filter { $0.isFinite && $0 > 0 }.max() ?? 0, 1) + } + + static func makeOnBoardRuns( + _ points: [OnBoardPoint], + maximumGap: TimeInterval = onBoardGapInterval + ) -> [OnBoardRun] { + guard let first = points.first else { return [] } + + var runs: [OnBoardRun] = [] + var runPoints = [first] + + for point in points.dropFirst() { + if let previous = runPoints.last, + point.date.timeIntervalSince(previous.date) > maximumGap + { + runs.append(OnBoardRun(id: runs.count, points: runPoints)) + runPoints = [point] + } else { + runPoints.append(point) + } + } + + runs.append(OnBoardRun(id: runs.count, points: runPoints)) + return runs + } + + static func nearestOnBoardPoint( + in points: [OnBoardPoint], + to date: Date, + tolerance: TimeInterval = onBoardSelectionTolerance + ) -> OnBoardPoint? { + guard !points.isEmpty else { return nil } + + var lower = 0 + var upper = points.count + while lower < upper { + let middle = (lower + upper) / 2 + if points[middle].date < date { + lower = middle + 1 + } else { + upper = middle + } + } + + var candidates: [OnBoardPoint] = [] + if lower < points.count { + candidates.append(points[lower]) + } + if lower > 0 { + candidates.append(points[lower - 1]) + } + + guard let nearest = candidates.min(by: { + abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) + }), abs(nearest.date.timeIntervalSince(date)) <= tolerance + else { + return nil + } + + return nearest + } + /// Minimum drawn spacing between two treatments of the same population, and /// the furthest a treatment may be moved from its true time to reach it. /// Boluses and SMBs share a y-anchor and symbol footprint, so they are @@ -393,6 +498,7 @@ final class BGChartModel: ObservableObject { private func performRebuild() { guard let vc = MainViewController.shared else { return } + let currentNow = Date(timeIntervalSince1970: dateTimeUtils.getNowTimeIntervalUTC()) pillTimeFormatter = Self.makePillTimeFormatter() let maxBGValue = Double(vc.calculateMaxBgGraphValue()) @@ -410,6 +516,7 @@ final class BGChartModel: ObservableObject { show30Min = Storage.shared.show30MinLine.value show90Min = Storage.shared.show90MinLine.value showMidnight = Storage.shared.showMidnightLines.value + showIOBCOBHistory = Storage.shared.showIOBCOBHistory.value smallGraphTreatments = Storage.shared.smallGraphTreatments.value // Advanced-settings visibility toggles. The Nightscout controllers @@ -448,6 +555,55 @@ final class BGChartModel: ObservableObject { cobPrediction = vc.cobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) } uamPrediction = vc.uamPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) } + if showIOBCOBHistory { + let historyCutoff = currentNow.addingTimeInterval( + -TimeInterval(max(Storage.shared.downloadDays.value, 1) * 24 * 3600) + ) + let samples = vc.deviceStatusMetricHistory.filter { + $0.date >= historyCutoff && $0.date <= currentNow.addingTimeInterval(10 * 60) + } + + iobHistory = samples.compactMap { sample in + guard let value = sample.iob, value.isFinite else { return nil } + return OnBoardPoint(date: sample.date, value: value) + } + cobHistory = samples.compactMap { sample in + guard let value = sample.cob, value.isFinite else { return nil } + return OnBoardPoint(date: sample.date, value: value) + } + iobHistoryMaximum = Self.onBoardMaximum(for: iobHistory) + cobHistoryMaximum = Self.onBoardMaximum(for: cobHistory) + iobHistoryRuns = Self.makeOnBoardRuns(iobHistory) + cobHistoryRuns = Self.makeOnBoardRuns(cobHistory) + + if let point = iobHistory.last, + currentNow.timeIntervalSince(point.date) >= -10 * 60, + currentNow.timeIntervalSince(point.date) <= Self.onBoardCurrentFreshness + { + currentIOB = point.value + } else { + currentIOB = nil + } + + if let point = cobHistory.last, + currentNow.timeIntervalSince(point.date) >= -10 * 60, + currentNow.timeIntervalSince(point.date) <= Self.onBoardCurrentFreshness + { + currentCOB = point.value + } else { + currentCOB = nil + } + } else { + iobHistory = [] + cobHistory = [] + iobHistoryRuns = [] + cobHistoryRuns = [] + iobHistoryMaximum = 1 + cobHistoryMaximum = 1 + currentIOB = nil + currentCOB = nil + } + let bolusPoints = (showBolus ? vc.bolusData : []).map { let dose = self.formatDose($0.value) return TreatmentPoint( @@ -562,7 +718,6 @@ final class BGChartModel: ObservableObject { ) } - let currentNow = Date(timeIntervalSince1970: dateTimeUtils.getNowTimeIntervalUTC()) now = currentNow let hoursBack = TimeInterval(Storage.shared.downloadDays.value * 24 * 3600) domainStart = currentNow.addingTimeInterval(-hoursBack) diff --git a/LoopFollow/Charts/BGChartStubs.swift b/LoopFollow/Charts/BGChartStubs.swift index 700821a66..b73919544 100644 --- a/LoopFollow/Charts/BGChartStubs.swift +++ b/LoopFollow/Charts/BGChartStubs.swift @@ -31,6 +31,10 @@ extension MainViewController { // The yesterday overlay is built during the BG fetch and needs an // extra day of history, so reload the BG window when it's toggled. TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) + + // Increasing "Show Days Back" requires a wider one-time device-status + // backfill for the on-board history overlay. + loadDeviceStatusMetricHistoryIfNeeded() } private func recomputeTopBG() { diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 9098bd123..03a06909b 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -57,6 +57,31 @@ private enum BGChartConfig { static let tapHitRadius: CGFloat = 30 } +struct BGChartTapCandidate { + let value: Value + let distanceSquared: CGFloat +} + +func nearestBGChartTapCandidate( + _ candidates: [BGChartTapCandidate], + within radius: CGFloat +) -> Value? { + let maximumDistanceSquared = radius * radius + var best: BGChartTapCandidate? + + for candidate in candidates where candidate.distanceSquared <= maximumDistanceSquared { + if let currentBest = best { + if candidate.distanceSquared < currentBest.distanceSquared { + best = candidate + } + } else { + best = candidate + } + } + + return best?.value +} + /// Small y-domain headroom keeps the top axis label readable instead of /// pinning it to the chart edge. private func chartYDomainUpperBound(_ maxBG: Double) -> Double { @@ -235,6 +260,9 @@ private struct MainBGChart: View { .frame(width: viewportWidth, height: viewport.height) .allowsHitTesting(false) + onBoardHistoryLegend(viewport: viewport) + .allowsHitTesting(false) + selectionOverlay(viewportWidth: viewportWidth) .allowsHitTesting(false) @@ -316,6 +344,23 @@ private struct MainBGChart: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } + @ViewBuilder + private func onBoardHistoryLegend(viewport: CGSize) -> some View { + if model.showIOBCOBHistory, + plotFrame.height > 0, + model.currentIOB != nil || model.currentCOB != nil + { + OnBoardHistoryLegend(iob: model.currentIOB, cob: model.currentCOB) + .padding(.trailing, 42) + .padding(.bottom, max(viewport.height - plotFrame.maxY + 4, 4)) + .frame( + width: max(viewport.width, 1), + height: max(viewport.height, 1), + alignment: .bottomTrailing + ) + } + } + // MARK: Render window / follow state /// Re-anchors the render window when the visible window nears its edge. @@ -686,23 +731,107 @@ private struct MainBGChart: View { let value: Double /// One pill entry per item under the selector (see PillLabel). let texts: [String] + /// The event time shown once on the pill's bottom line. This can differ + /// from `date` when treatment decluttering shifts a symbol horizontally. + let timestamp: Date + + init(date: Date, value: Double, texts: [String], timestamp: Date? = nil) { + self.date = date + self.value = value + self.texts = texts + self.timestamp = timestamp ?? date + } + } + + private struct OnBoardSelection { + let date: Date + let plotValue: Double + let text: String } - /// Feeds every treatment mark to `body` as (drawnDate, value, pillText). + /// Feeds every treatment mark to `body` as + /// (drawnDate, eventDate, value, undatedPillText). /// Single source for both the scrub lookup and the tap hit test. - private func forEachTreatmentAnchor(_ body: (Date, Double, String) -> Void) { + private func forEachTreatmentAnchor(_ body: (Date, Date, Double, String) -> Void) { for group in [model.boluses, model.carbs, model.smbs, model.bgChecks, model.notes, model.suspends, model.resumes, model.sensorStarts] { for t in group { - body(t.drawnDate, t.sgv, t.pillText) + body(t.drawnDate, t.date, t.sgv, withoutTrailingTime(t.pillText)) + } + } + } + + private func forEachBGAnchor(_ body: (BGChartModel.BGPoint) -> Void) { + for group in [model.bg, model.prediction, model.ztPrediction, + model.iobPrediction, model.cobPrediction, model.uamPrediction] + { + for point in group { + body(point) } } } + private func withoutTrailingTime(_ text: String) -> String { + guard let finalLineBreak = text.lastIndex(of: "\n") else { return text } + return String(text[.. String { - "BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))\n\(model.pillTimeString(for: point.date))" + "BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))" + } + + private func onBoardSelection(near date: Date) -> OnBoardSelection? { + let iob = BGChartModel.nearestOnBoardPoint(in: model.iobHistory, to: date) + let cob = BGChartModel.nearestOnBoardPoint(in: model.cobHistory, to: date) + guard iob != nil || cob != nil else { return nil } + + var values: [String] = [] + if let iob { + let digits = abs(iob.value) >= 10 ? 0 : 1 + let value = Localizer.formatToLocalizedString( + iob.value, + maxFractionDigits: digits, + minFractionDigits: 0 + ) + values.append("IOB \(value)U") + } + if let cob { + let value = Localizer.formatToLocalizedString( + cob.value, + maxFractionDigits: 0, + minFractionDigits: 0 + ) + values.append("COB \(value)g") + } + + let anchor: (point: BGChartModel.OnBoardPoint, maximum: Double) + if let iob, let cob { + if abs(iob.date.timeIntervalSince(date)) <= abs(cob.date.timeIntervalSince(date)) { + anchor = (iob, model.iobHistoryMaximum) + } else { + anchor = (cob, model.cobHistoryMaximum) + } + } else if let iob { + anchor = (iob, model.iobHistoryMaximum) + } else if let cob { + anchor = (cob, model.cobHistoryMaximum) + } else { + return nil + } + + let laneCeiling = BGChartModel.onBoardLaneCeiling( + maxBG: model.maxBG, + lowLine: model.lowLine + ) + let plotValue = BGChartModel.scaledOnBoardValue( + anchor.point.value, + maximum: anchor.maximum, + laneCeiling: laneCeiling + ) + let text = values.joined(separator: " • ") + return OnBoardSelection(date: anchor.point.date, plotValue: plotValue, text: text) } private func bandPillTexts(at date: Date) -> [String] { @@ -711,13 +840,13 @@ private struct MainBGChart: View { .filter({ date >= $0.start && date <= $0.end }) .max(by: { $0.start < $1.start }) { - texts.append(band.pillText) + texts.append(withoutTrailingTime(band.pillText)) } if let band = model.tempTargets .filter({ date >= $0.start && date <= $0.end }) .max(by: { $0.start < $1.start }) { - texts.append(band.pillText) + texts.append(withoutTrailingTime(band.pillText)) } return texts } @@ -727,13 +856,23 @@ private struct MainBGChart: View { for band in model.overrides where date >= band.start && date <= band.end { if value >= band.yBottom, value <= band.yTop { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: date, value: midY, texts: [band.pillText]) + return SelectionAnchor( + date: date, + value: midY, + texts: [withoutTrailingTime(band.pillText)], + timestamp: band.start + ) } } for band in model.tempTargets where date >= band.start && date <= band.end { if value >= band.yBottom, value <= band.yTop { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: date, value: midY, texts: [band.pillText]) + return SelectionAnchor( + date: date, + value: midY, + texts: [withoutTrailingTime(band.pillText)], + timestamp: band.start + ) } } return nil @@ -757,6 +896,7 @@ private struct MainBGChart: View { private func selectionAnchor(for selected: Date, captureWindow: TimeInterval) -> SelectionAnchor? { struct Item { let date: Date + let timestamp: Date let value: Double let text: String let distance: TimeInterval @@ -764,8 +904,14 @@ private struct MainBGChart: View { var captured: [Item] = [] var nearestTreatment: Item? - forEachTreatmentAnchor { date, value, text in - let item = Item(date: date, value: value, text: text, distance: abs(date.timeIntervalSince(selected))) + forEachTreatmentAnchor { date, timestamp, value, text in + let item = Item( + date: date, + timestamp: timestamp, + value: value, + text: text, + distance: abs(date.timeIntervalSince(selected)) + ) if item.distance <= captureWindow { captured.append(item) } @@ -776,10 +922,16 @@ private struct MainBGChart: View { captured.sort { $0.date < $1.date } var nearestBG: Item? - for p in model.bg { + forEachBGAnchor { p in let d = abs(p.date.timeIntervalSince(selected)) if d < (nearestBG?.distance ?? .greatestFiniteMagnitude) { - nearestBG = Item(date: p.date, value: p.value, text: bgPillText(for: p), distance: d) + nearestBG = Item( + date: p.date, + timestamp: p.date, + value: p.value, + text: bgPillText(for: p), + distance: d + ) } } @@ -787,65 +939,152 @@ private struct MainBGChart: View { if let nearestBG, nearestBG.distance <= BGChartConfig.selectionTolerance { items.append(nearestBG) } + let onBoard = onBoardSelection(near: selected) if let primary = items.min(by: { $0.distance < $1.distance }) { - let texts = items.map(\.text) + bandPillTexts(at: selected) - return SelectionAnchor(date: primary.date, value: primary.value, texts: texts) + let texts = items.map(\.text) + + (onBoard.map { [$0.text] } ?? []) + + bandPillTexts(at: selected) + return SelectionAnchor( + date: primary.date, + value: primary.value, + texts: texts, + timestamp: primary.timestamp + ) } // Nothing under the finger. Reach for the nearest treatment (data gaps // leave treatments without BG neighbors), then for a band (any height) // at the scrub time. if let nearestTreatment, nearestTreatment.distance <= BGChartConfig.selectionTolerance { - let texts = [nearestTreatment.text] + bandPillTexts(at: selected) - return SelectionAnchor(date: nearestTreatment.date, value: nearestTreatment.value, texts: texts) + let texts = [nearestTreatment.text] + + (onBoard.map { [$0.text] } ?? []) + + bandPillTexts(at: selected) + return SelectionAnchor( + date: nearestTreatment.date, + value: nearestTreatment.value, + texts: texts, + timestamp: nearestTreatment.timestamp + ) + } + if let onBoard { + return SelectionAnchor( + date: onBoard.date, + value: onBoard.plotValue, + texts: [onBoard.text] + bandPillTexts(at: selected) + ) } for band in model.overrides where selected >= band.start && selected <= band.end { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) + return SelectionAnchor( + date: selected, + value: midY, + texts: [withoutTrailingTime(band.pillText)], + timestamp: band.start + ) } for band in model.tempTargets where selected >= band.start && selected <= band.end { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) + return SelectionAnchor( + date: selected, + value: midY, + texts: [withoutTrailingTime(band.pillText)], + timestamp: band.start + ) } return nil } - /// Tap hit test (screen-space, 2D). Treatments take priority, then BG - /// points, then the override/temp-target bands under the finger. + /// Tap hit test (screen-space, 2D). The nearest treatment, BG, or forecast + /// sample wins; on-board traces and bands are fallbacks. /// Returns nil when the tap lands on nothing — which clears the pill. private func tappedAnchor(at location: CGPoint, viewportWidth: CGFloat) -> SelectionAnchor? { let radius = BGChartConfig.tapHitRadius - var best: SelectionAnchor? - var bestDistance2 = radius * radius - - func consider(_ date: Date, _ value: Double, _ text: String) { + var primaryCandidates: [BGChartTapCandidate] = [] + + func consider( + _ date: Date, + timestamp: Date, + value: Double, + text: String + ) { let dx = xPosition(for: date, viewportWidth: viewportWidth) - location.x let dy = yPosition(forValue: value) - location.y - let d2 = dx * dx + dy * dy - if d2 <= bestDistance2 { - bestDistance2 = d2 - best = SelectionAnchor(date: date, value: value, texts: [text]) - } + primaryCandidates.append(BGChartTapCandidate( + value: SelectionAnchor( + date: date, + value: value, + texts: [text], + timestamp: timestamp + ), + distanceSquared: dx * dx + dy * dy + )) + } + + forEachTreatmentAnchor { date, timestamp, value, text in + consider(date, timestamp: timestamp, value: value, text: text) + } + forEachBGAnchor { point in + consider( + point.date, + timestamp: point.date, + value: point.value, + text: bgPillText(for: point) + ) + } + if let best = nearestBGChartTapCandidate(primaryCandidates, within: radius) { + let metricText = onBoardSelection(near: best.date).map { [$0.text] } ?? [] + let texts = best.texts + metricText + bandPillTexts(at: best.date) + return SelectionAnchor( + date: best.date, + value: best.value, + texts: texts, + timestamp: best.timestamp + ) } - forEachTreatmentAnchor(consider) - if best == nil { - for p in model.bg { - consider(p.date, p.value, bgPillText(for: p)) + var bestOnBoard: SelectionAnchor? + var bestOnBoardDistance2 = radius * radius + func considerOnBoard(_ point: BGChartModel.OnBoardPoint, maximum: Double) { + let laneCeiling = BGChartModel.onBoardLaneCeiling( + maxBG: model.maxBG, + lowLine: model.lowLine + ) + let plotValue = BGChartModel.scaledOnBoardValue( + point.value, + maximum: maximum, + laneCeiling: laneCeiling + ) + let dx = xPosition(for: point.date, viewportWidth: viewportWidth) - location.x + let dy = yPosition(forValue: plotValue) - location.y + let distance2 = dx * dx + dy * dy + guard distance2 <= bestOnBoardDistance2, + let selection = onBoardSelection(near: point.date) + else { + return } - } - if best == nil { - let date = interaction.scrollPosition.addingTimeInterval( - interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) + bestOnBoardDistance2 = distance2 + bestOnBoard = SelectionAnchor( + date: point.date, + value: plotValue, + texts: [selection.text] + bandPillTexts(at: point.date) ) - return bandAnchor(at: date, value: value(atY: location.y)) } - if let best { - let texts = best.texts + bandPillTexts(at: best.date) - return SelectionAnchor(date: best.date, value: best.value, texts: texts) + + for point in model.iobHistory { + considerOnBoard(point, maximum: model.iobHistoryMaximum) } - return nil + for point in model.cobHistory { + considerOnBoard(point, maximum: model.cobHistoryMaximum) + } + if let bestOnBoard { + return bestOnBoard + } + + let date = interaction.scrollPosition.addingTimeInterval( + interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) + ) + return bandAnchor(at: date, value: value(atY: location.y)) } private func handleTap(at location: CGPoint, viewportWidth: CGFloat) { @@ -941,8 +1180,12 @@ private struct MainBGChart: View { let above = y - 14 - pillH / 2 let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4 let labelY = fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4) - PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16)) - .position(x: labelX, y: labelY) + PillLabel( + texts: anchor.texts, + timeText: model.pillTimeString(for: anchor.timestamp), + maxWidth: min(300, viewportWidth - 16) + ) + .position(x: labelX, y: labelY) } } } @@ -1076,14 +1319,34 @@ private struct BGChartCanvas: View, Equatable { return model.maxBG / model.maxBasal } + private var onBoardLaneCeiling: Double { + BGChartModel.onBoardLaneCeiling(maxBG: model.maxBG, lowLine: model.lowLine) + } + + private func onBoardPlotValue(_ value: Double, maximum: Double) -> Double { + BGChartModel.scaledOnBoardValue( + value, + maximum: maximum, + laneCeiling: onBoardLaneCeiling + ) + } + var body: some View { let showTreatments = !isSmall || model.smallGraphTreatments let chart = Chart { if showTreatments { bgBandMarks basalMarks + } + if !isSmall { + onBoardHistoryAreaMarks + } + if showTreatments { scheduledBasalMarks } + if !isSmall { + onBoardHistoryLineMarks + } coneMarks if !isSmall { yesterdayMarks @@ -1235,6 +1498,76 @@ private struct BGChartCanvas: View, Equatable { } } + @ChartContentBuilder + private var onBoardHistoryAreaMarks: some ChartContent { + ForEach(model.iobHistoryRuns) { run in + ForEach(windowedLine(run.points) { $0.date }) { point in + AreaMark( + x: .value("time", point.date), + yStart: .value("iob history baseline", 0), + yEnd: .value( + "iob history", + onBoardPlotValue(point.value, maximum: model.iobHistoryMaximum) + ), + series: .value("series", "iob-history-area-\(run.id)") + ) + .foregroundStyle(Color("Insulin").opacity(0.14)) + .interpolationMethod(.linear) + } + } + + ForEach(model.cobHistoryRuns) { run in + ForEach(windowedLine(run.points) { $0.date }) { point in + AreaMark( + x: .value("time", point.date), + yStart: .value("cob history baseline", 0), + yEnd: .value( + "cob history", + onBoardPlotValue(point.value, maximum: model.cobHistoryMaximum) + ), + series: .value("series", "cob-history-area-\(run.id)") + ) + .foregroundStyle(Color(.systemOrange).opacity(0.2)) + .interpolationMethod(.linear) + } + } + } + + @ChartContentBuilder + private var onBoardHistoryLineMarks: some ChartContent { + ForEach(model.iobHistoryRuns) { run in + ForEach(windowedLine(run.points) { $0.date }) { point in + LineMark( + x: .value("time", point.date), + y: .value( + "iob history", + onBoardPlotValue(point.value, maximum: model.iobHistoryMaximum) + ), + series: .value("series", "iob-history-line-\(run.id)") + ) + .foregroundStyle(Color("Insulin")) + .lineStyle(StrokeStyle(lineWidth: 2)) + .interpolationMethod(.linear) + } + } + + ForEach(model.cobHistoryRuns) { run in + ForEach(windowedLine(run.points) { $0.date }) { point in + LineMark( + x: .value("time", point.date), + y: .value( + "cob history", + onBoardPlotValue(point.value, maximum: model.cobHistoryMaximum) + ), + series: .value("series", "cob-history-line-\(run.id)") + ) + .foregroundStyle(Color(.systemOrange)) + .lineStyle(StrokeStyle(lineWidth: 2)) + .interpolationMethod(.linear) + } + } + } + @ChartContentBuilder private var scheduledBasalMarks: some ChartContent { ForEach(windowedLine(model.basalScheduled) { $0.date }) { pt in @@ -1614,11 +1947,72 @@ private struct DownwardTriangle: ChartSymbolShape { } } +private struct OnBoardHistoryLegend: View { + let iob: Double? + let cob: Double? + + var body: some View { + HStack(spacing: 7) { + if let iob { + item( + color: Color("Insulin"), + text: "IOB \(format(iob, fractionDigits: abs(iob) >= 10 ? 0 : 1))U" + ) + } + if let cob { + item( + color: Color(.systemOrange), + text: "COB \(format(cob, fractionDigits: 0))g" + ) + } + } + .font(.caption2.monospacedDigit()) + .foregroundStyle(.primary) + .padding(.horizontal, 5) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color(.systemBackground).opacity(0.82)) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText) + } + + private func item(color: Color, text: String) -> some View { + HStack(spacing: 3) { + Rectangle() + .fill(color) + .frame(width: 12, height: 2) + Text(text) + } + } + + private func format(_ value: Double, fractionDigits: Int) -> String { + Localizer.formatToLocalizedString( + value, + maxFractionDigits: fractionDigits, + minFractionDigits: 0 + ) + } + + private var accessibilityText: String { + var parts: [String] = [] + if let iob { + parts.append("IOB \(format(iob, fractionDigits: abs(iob) >= 10 ? 0 : 1)) units") + } + if let cob { + parts.append("COB \(format(cob, fractionDigits: 0)) grams") + } + return parts.joined(separator: ", ") + } +} + private struct PillLabel: View { /// One entry per selected item. A lone entry keeps its multi-line layout; /// several stack as compact one-line-per-item rows so the pill stays /// readable over a busy cluster. let texts: [String] + let timeText: String let maxWidth: CGFloat var body: some View { @@ -1646,9 +2040,12 @@ private struct PillLabel: View { @ViewBuilder private var content: some View { if texts.count == 1 { - // Bound pathological texts; a note this long is better read in - // Nightscout than on a chart pill. - entry(texts[0], lineLimit: 10) + VStack(spacing: 1) { + // Bound pathological texts; a note this long is better read in + // Nightscout than on a chart pill. + entry(texts[0], lineLimit: 9) + entry(timeText, lineLimit: 1) + } } else { VStack(spacing: 3) { ForEach(texts.indices, id: \.self) { index in @@ -1659,9 +2056,10 @@ private struct PillLabel: View { .fill(Color.primary.opacity(0.25)) .frame(width: 46, height: 0.5) } - // Stacked items collapse to "Bolus 2.5U 14:32" rows. + // Stacked items collapse to compact "Bolus 2.5U" rows. entry(texts[index].replacingOccurrences(of: "\n", with: " "), lineLimit: 4) } + entry(timeText, lineLimit: 1) } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 89126e6b1..f6ce81407 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -7,20 +7,37 @@ import SwiftUI extension MainViewController { func webLoadNSDeviceStatus() { + prepareDeviceStatusMetricHistorySource() + let requestedSource = deviceStatusMetricHistorySource + deviceStatusRequestGeneration += 1 + let requestGeneration = deviceStatusRequestGeneration let parameters = ["count": "1"] NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in - switch result { - case let .success(json): - if let jsonDeviceStatus = json as? [[String: AnyObject]] { - DispatchQueue.main.async { + DispatchQueue.main.async { + guard requestedSource == self.deviceStatusMetricHistorySource, + requestGeneration == self.deviceStatusRequestGeneration + else { + return + } + + switch result { + case let .success(json): + if let jsonDeviceStatus = json as? [[String: AnyObject]] { + let currentDevice = DeviceStatusMetricHistoryParser + .newestEntry(in: jsonDeviceStatus)?["device"] as? String + self.prepareDeviceStatusMetricHistoryDevice(currentDevice) + self.mergeCurrentDeviceStatusMetricHistory( + DeviceStatusMetricHistoryParser.samples(from: jsonDeviceStatus) + ) self.updateDeviceStatusDisplay(jsonDeviceStatus: jsonDeviceStatus) + self.loadDeviceStatusMetricHistoryIfNeeded() Storage.shared.lastLoopingChecked.value = Date() + } else { + self.handleDeviceStatusError() } - } else { + case .failure: self.handleDeviceStatusError() } - case .failure: - self.handleDeviceStatusError() } } } @@ -81,7 +98,7 @@ extension MainViewController { } // Process the current data first - let lastDeviceStatus = jsonDeviceStatus[0] as [String: AnyObject]? + let lastDeviceStatus = DeviceStatusMetricHistoryParser.newestEntry(in: jsonDeviceStatus) // pump and uploader let formatter = ISO8601DateFormatter() @@ -92,7 +109,7 @@ extension MainViewController { Observable.shared.previousAlertLastLoopTime.value = Observable.shared.alertLastLoopTime.value - if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? { + if let lastPumpRecord = lastDeviceStatus?["pump"] as? [String: AnyObject] { if let bolusIncrement = lastPumpRecord["bolusIncrement"] as? Double, bolusIncrement > 0 { Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: bolusIncrement) Storage.shared.bolusIncrementDetected.value = true @@ -159,7 +176,7 @@ extension MainViewController { } // Loop - handle new data - if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? { + if let lastLoopRecord = lastDeviceStatus?["loop"] as? [String: AnyObject] { // Some pumps report no `pump.clock`; without it alertLastLoopTime stays 0 // and the forecast anchors to epoch 0. Fall back to the loop cycle timestamp. if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] == nil, @@ -200,7 +217,7 @@ extension MainViewController { } // OpenAPS - handle new data - if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String: AnyObject]? { + if let lastLoopRecord = lastDeviceStatus?["openaps"] as? [String: AnyObject] { DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord) } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusMetricHistory.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusMetricHistory.swift new file mode 100644 index 000000000..1de8dd3fd --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusMetricHistory.swift @@ -0,0 +1,491 @@ +// LoopFollow +// DeviceStatusMetricHistory.swift + +import CoreFoundation +import Foundation + +struct DeviceStatusMetricSample: Equatable { + let date: Date + let iob: Double? + let cob: Double? +} + +enum DeviceStatusMetricHistoryParser { + private static let fractionalDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let internetDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static let timezoneLessDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return formatter + }() + + private static let cobReasonRegex = try? NSRegularExpression( + pattern: #"\bCOB:\s*(-?\d+(?:\.\d+)?)"# + ) + + static func samples( + from entries: [[String: AnyObject]], + cutoff: Date = .distantPast, + now: Date = Date() + ) -> [DeviceStatusMetricSample] { + var samplesByTimestamp: [TimeInterval: DeviceStatusMetricSample] = [:] + let newestAllowedDate = now.addingTimeInterval(10 * 60) + + // Nightscout normally returns newest-first, but sorting by the upload + // timestamp makes "keep first" deterministic for duplicate records. + let orderedEntries = entries.enumerated() + .map { offset, entry in + (entry: entry, uploadDate: recordTimestamp(from: entry), offset: offset) + } + .sorted { lhs, rhs in + if lhs.uploadDate == rhs.uploadDate { + return lhs.offset < rhs.offset + } + return (lhs.uploadDate ?? .distantPast) > (rhs.uploadDate ?? .distantPast) + } + .map { $0.entry } + + for entry in orderedEntries { + for sample in samples(from: entry) where sample.date >= cutoff && sample.date <= newestAllowedDate { + let timestamp = sample.date.timeIntervalSince1970 + + if let existing = samplesByTimestamp[timestamp] { + samplesByTimestamp[timestamp] = DeviceStatusMetricSample( + date: existing.date, + iob: existing.iob ?? sample.iob, + cob: existing.cob ?? sample.cob + ) + } else { + samplesByTimestamp[timestamp] = sample + } + } + } + + return samplesByTimestamp.values.sorted { $0.date < $1.date } + } + + static func newestEntry(in entries: [[String: AnyObject]]) -> [String: AnyObject]? { + entries.max { lhs, rhs in + (recordTimestamp(from: lhs)?.timeIntervalSince1970 ?? -.greatestFiniteMagnitude) + < (recordTimestamp(from: rhs)?.timeIntervalSince1970 ?? -.greatestFiniteMagnitude) + } ?? entries.first + } + + private static func samples(from entry: [String: AnyObject]) -> [DeviceStatusMetricSample] { + let recordDate = recordTimestamp(from: entry) + var samples: [DeviceStatusMetricSample] = [] + + if let loop = dictionary(entry["loop"]) { + let loopDate = timestamp(in: loop, keys: ["timestamp", "time", "mills"]) ?? recordDate + + if let iobRecord = dictionary(loop["iob"]), + let iob = firstNumber(in: iobRecord, keys: ["iob", "IOB"]), + let date = timestamp(in: iobRecord, keys: ["timestamp", "time", "mills"]) ?? loopDate + { + samples.append(DeviceStatusMetricSample(date: date, iob: iob, cob: nil)) + } + + if let cobRecord = dictionary(loop["cob"]), + let cob = firstNumber(in: cobRecord, keys: ["cob", "COB"]), + let date = timestamp(in: cobRecord, keys: ["timestamp", "time", "mills"]) ?? loopDate + { + samples.append(DeviceStatusMetricSample(date: date, iob: nil, cob: cob)) + } + } + + if let openAPS = dictionary(entry["openaps"]) { + let determinations = ["suggested", "enacted"] + .compactMap { dictionary(openAPS[$0]) } + .enumerated() + .map { offset, record in + ( + record: record, + date: timestamp( + in: record, + keys: ["deliverAt", "timestamp", "time", "mills"] + ), + order: offset + ) + } + .sorted { lhs, rhs in + if lhs.date == rhs.date { + return lhs.order < rhs.order + } + return (lhs.date ?? .distantPast) > (rhs.date ?? .distantPast) + } + + if let iobDetermination = determinations.first(where: { + firstNumber(in: $0.record, keys: ["IOB", "iob"]) != nil && $0.date != nil + }), let iob = firstNumber(in: iobDetermination.record, keys: ["IOB", "iob"]), + let date = iobDetermination.date { + samples.append(DeviceStatusMetricSample(date: date, iob: iob, cob: nil)) + } else if let iobRecord = firstMetricRecord(openAPS["iob"]), + let iob = firstNumber(in: iobRecord, keys: ["iob", "IOB"]), + let date = timestamp( + in: iobRecord, + keys: ["time", "timestamp", "date", "mills"] + ) ?? recordDate + { + samples.append(DeviceStatusMetricSample(date: date, iob: iob, cob: nil)) + } + + if let cobDetermination = determinations.first(where: { + firstNumber(in: $0.record, keys: ["COB", "cob"]) != nil && $0.date != nil + }), let cob = firstNumber(in: cobDetermination.record, keys: ["COB", "cob"]), + let date = cobDetermination.date { + samples.append(DeviceStatusMetricSample(date: date, iob: nil, cob: cob)) + } else if let reasonDetermination = determinations.first(where: { + cobFromReason($0.record["reason"] as? String) != nil && $0.date != nil + }), let cob = cobFromReason(reasonDetermination.record["reason"] as? String), + let date = reasonDetermination.date { + samples.append(DeviceStatusMetricSample(date: date, iob: nil, cob: cob)) + } + } + + return samples + } + + private static func recordTimestamp(from entry: [String: AnyObject]) -> Date? { + for key in ["created_at", "dateString"] { + if let date = date(entry[key]) { + return date + } + } + + for key in ["date", "mills"] { + if let date = date(entry[key]) { + return date + } + } + + if let pump = dictionary(entry["pump"]), + let date = timestamp(in: pump, keys: ["clock", "timestamp", "mills"]) + { + return date + } + + if let loop = dictionary(entry["loop"]), + let date = timestamp(in: loop, keys: ["timestamp", "time", "mills"]) + { + return date + } + + if let openAPS = dictionary(entry["openaps"]) { + for recordKey in ["suggested", "enacted"] { + if let record = dictionary(openAPS[recordKey]), + let date = timestamp( + in: record, + keys: ["deliverAt", "timestamp", "time", "mills"] + ) + { + return date + } + } + } + + return nil + } + + private static func dictionary(_ value: AnyObject?) -> [String: AnyObject]? { + value as? [String: AnyObject] + } + + private static func firstMetricRecord(_ value: AnyObject?) -> [String: AnyObject]? { + if let dictionary = dictionary(value) { + return dictionary + } + let records: [[String: AnyObject]] + if let dictionaries = value as? [[String: AnyObject]] { + records = dictionaries + } else if let objects = value as? [AnyObject] { + records = objects.compactMap(dictionary) + } else { + return nil + } + + return records.enumerated() + .map { offset, record in + ( + record: record, + date: timestamp(in: record, keys: ["time", "timestamp", "date", "mills"]), + offset: offset + ) + } + .sorted { lhs, rhs in + if lhs.date == rhs.date { + return lhs.offset < rhs.offset + } + return (lhs.date ?? .distantPast) > (rhs.date ?? .distantPast) + } + .first?.record + } + + private static func firstNumber(in dictionary: [String: AnyObject]?, keys: [String]) -> Double? { + guard let dictionary else { return nil } + for key in keys { + if let value = number(dictionary[key]) { + return value + } + } + return nil + } + + private static func number(_ value: AnyObject?) -> Double? { + let parsed: Double? + if let number = value as? NSNumber { + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + parsed = number.doubleValue + } else if let string = value as? String { + parsed = Double(string) + } else { + parsed = nil + } + + guard let parsed, parsed.isFinite else { return nil } + return parsed + } + + private static func timestamp(in dictionary: [String: AnyObject], keys: [String]) -> Date? { + for key in keys { + if let date = date(dictionary[key]) { + return date + } + } + return nil + } + + private static func date(_ value: AnyObject?) -> Date? { + if let number = number(value) { + let seconds = number > 10_000_000_000 ? number / 1000 : number + return Date(timeIntervalSince1970: seconds) + } + + guard let string = value as? String else { return nil } + if let numeric = Double(string) { + let seconds = numeric > 10_000_000_000 ? numeric / 1000 : numeric + return Date(timeIntervalSince1970: seconds) + } + + if let date = fractionalDateFormatter.date(from: string) { + return date + } + + if let date = internetDateFormatter.date(from: string) { + return date + } + + return timezoneLessDateFormatter.date(from: string) + } + + private static func cobFromReason(_ reason: String?) -> Double? { + guard let reason, + let regex = cobReasonRegex, + let match = regex.firstMatch( + in: reason, + range: NSRange(reason.startIndex ..< reason.endIndex, in: reason) + ), + let valueRange = Range(match.range(at: 1), in: reason) + else { + return nil + } + + return Double(reason[valueRange]) + } +} + +extension MainViewController { + func prepareDeviceStatusMetricHistorySource() { + let source = Storage.shared.url.value + "\u{0}" + Storage.shared.token.value + guard source != deviceStatusMetricHistorySource else { return } + + deviceStatusMetricHistoryGeneration += 1 + deviceStatusRequestGeneration += 1 + deviceStatusMetricHistorySource = source + deviceStatusMetricHistoryDevice = "" + deviceStatusMetricHistoryLoadedDays = 0 + isLoadingDeviceStatusMetricHistory = false + deviceStatusMetricHistory = [] + chartModel.rebuild() + } + + func prepareDeviceStatusMetricHistoryDevice(_ device: String?) { + let device = device ?? "" + guard device != deviceStatusMetricHistoryDevice else { return } + + deviceStatusMetricHistoryGeneration += 1 + deviceStatusMetricHistoryDevice = device + deviceStatusMetricHistoryLoadedDays = 0 + isLoadingDeviceStatusMetricHistory = false + deviceStatusMetricHistory = [] + chartModel.rebuild() + } + + func mergeCurrentDeviceStatusMetricHistory(_ incoming: [DeviceStatusMetricSample]) { + if let previousNewest = deviceStatusMetricHistory.last?.date, + let incomingNewest = incoming.last?.date, + incomingNewest.timeIntervalSince(previousNewest) > 15 * 60 + { + // The app was suspended or offline long enough to miss samples. + // Re-run the one-time backfill so the visible gap is recovered. + deviceStatusMetricHistoryLoadedDays = 0 + } + + mergeDeviceStatusMetricHistory(incoming) + } + + func mergeDeviceStatusMetricHistory( + _ incoming: [DeviceStatusMetricSample], + preferIncoming: Bool = true + ) { + guard !incoming.isEmpty else { return } + + // Retain the widest history already loaded. This avoids discarding + // samples when Show Days Back is temporarily reduced and then restored. + let retentionDays = max( + max(Storage.shared.downloadDays.value, deviceStatusMetricHistoryLoadedDays), + 1 + ) + let cutoff = Date().addingTimeInterval( + -TimeInterval(retentionDays * 24 * 3600) + ) + var samplesByTimestamp = Dictionary( + uniqueKeysWithValues: deviceStatusMetricHistory.map { + ($0.date.timeIntervalSince1970, $0) + } + ) + + for sample in incoming where sample.date >= cutoff { + let timestamp = sample.date.timeIntervalSince1970 + if let existing = samplesByTimestamp[timestamp] { + samplesByTimestamp[timestamp] = DeviceStatusMetricSample( + date: existing.date, + iob: preferIncoming ? (sample.iob ?? existing.iob) : (existing.iob ?? sample.iob), + cob: preferIncoming ? (sample.cob ?? existing.cob) : (existing.cob ?? sample.cob) + ) + } else { + samplesByTimestamp[timestamp] = sample + } + } + + deviceStatusMetricHistory = samplesByTimestamp.values + .filter { $0.date >= cutoff } + .sorted { $0.date < $1.date } + chartModel.rebuild() + } + + func loadDeviceStatusMetricHistoryIfNeeded() { + let requestedDays = max(Storage.shared.downloadDays.value, 1) + guard IsNightscoutEnabled(), + Storage.shared.showIOBCOBHistory.value, + deviceStatusMetricHistoryLoadedDays < requestedDays, + !isLoadingDeviceStatusMetricHistory + else { + return + } + + isLoadingDeviceStatusMetricHistory = true + deviceStatusMetricHistoryGeneration += 1 + let requestGeneration = deviceStatusMetricHistoryGeneration + let requestedSource = deviceStatusMetricHistorySource + let now = Date() + let cutoff = now.addingTimeInterval(-TimeInterval(requestedDays * 24 * 3600)) + let upperBound = now.addingTimeInterval(10 * 60) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + // Filtering by the current uploader avoids mixing unrelated Loop, + // Trio, and xDrip status streams and keeps this one-time request small. + let uploaderHeadroom = deviceStatusMetricHistoryDevice.isEmpty ? 4 : 2 + let estimatedCount = requestedDays * 24 * 12 * uploaderHeadroom + var parameters = [ + "find[created_at][$gte]": formatter.string(from: cutoff), + "find[created_at][$lte]": formatter.string(from: upperBound), + "count": "\(estimatedCount)", + ] + if !deviceStatusMetricHistoryDevice.isEmpty { + parameters["find[device]"] = deviceStatusMetricHistoryDevice + } + + NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { result in + DispatchQueue.main.async { + guard requestedSource == self.deviceStatusMetricHistorySource, + requestGeneration == self.deviceStatusMetricHistoryGeneration + else { + return + } + + switch result { + case let .success(json): + guard let entries = json as? [[String: AnyObject]] else { + self.isLoadingDeviceStatusMetricHistory = false + LogManager.shared.log( + category: .deviceStatus, + message: "Device status history returned an unexpected data structure" + ) + return + } + + DispatchQueue.global(qos: .utility).async { + let samples = DeviceStatusMetricHistoryParser.samples( + from: entries, + cutoff: cutoff, + now: now + ) + + DispatchQueue.main.async { + guard requestedSource == self.deviceStatusMetricHistorySource, + requestGeneration == self.deviceStatusMetricHistoryGeneration + else { + return + } + self.isLoadingDeviceStatusMetricHistory = false + self.deviceStatusMetricHistoryLoadedDays = requestedDays + // A count=1 response may have arrived while the backfill + // was parsing. Preserve those fresher samples on collision. + self.mergeDeviceStatusMetricHistory(samples, preferIncoming: false) + self.loadDeviceStatusMetricHistoryIfNeeded() + } + } + + case let .failure(error): + self.isLoadingDeviceStatusMetricHistory = false + LogManager.shared.log( + category: .deviceStatus, + message: "Device status history fetch failed: \(error.localizedDescription)", + limitIdentifier: "Device status history fetch failed" + ) + } + } + } + } + + func clearDeviceStatusMetricHistory() { + guard !deviceStatusMetricHistory.isEmpty + || deviceStatusMetricHistoryLoadedDays != 0 + || isLoadingDeviceStatusMetricHistory + || !deviceStatusMetricHistorySource.isEmpty + else { + return + } + + deviceStatusMetricHistoryGeneration += 1 + deviceStatusMetricHistory = [] + deviceStatusMetricHistoryLoadedDays = 0 + isLoadingDeviceStatusMetricHistory = false + deviceStatusMetricHistorySource = "" + deviceStatusMetricHistoryDevice = "" + chartModel.rebuild() + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 7dfdb4cdb..ec4a44ee6 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -11,7 +11,9 @@ extension MainViewController { Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { - guard let enactedOrSuggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? lastLoopRecord["enacted"] as? [String: AnyObject] else { + let suggested = lastLoopRecord["suggested"] as? [String: AnyObject] + let enacted = lastLoopRecord["enacted"] as? [String: AnyObject] + guard let enactedOrSuggested = suggested ?? enacted else { Observable.shared.loopStatusText.value = "↻" latestLoopStatusString = "↻" return @@ -79,10 +81,15 @@ extension MainViewController { } // COB - if let cobMetric = CarbMetric(from: enactedOrSuggested, key: "COB") { + if let cobMetric = suggested.flatMap({ CarbMetric(from: $0, key: "COB") }) + ?? enacted.flatMap({ CarbMetric(from: $0, key: "COB") }) + { infoManager.updateInfoData(type: .cob, value: cobMetric) latestCOB = cobMetric - } else if let reasonString = enactedOrSuggested["reason"] as? String { + } else if let reasonString = [suggested, enacted] + .compactMap({ $0?["reason"] as? String }) + .first(where: { $0.range(of: "COB:") != nil }) + { // Fallback: Extract COB from reason string let cobPattern = "COB: (\\d+(?:\\.\\d+)?)" if let cobRegex = try? NSRegularExpression(pattern: cobPattern), diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 07d9d8d91..ea17c2093 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -13,6 +13,7 @@ struct GraphSettingsView: View { @ObservedObject private var show90MinLine = Storage.shared.show90MinLine @ObservedObject private var showMidnightLines = Storage.shared.showMidnightLines @ObservedObject private var showYesterdayLine = Storage.shared.showYesterdayLine + @ObservedObject private var showIOBCOBHistory = Storage.shared.showIOBCOBHistory @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight @@ -55,6 +56,8 @@ struct GraphSettingsView: View { // ── Treatments ─────────────────────────────────────────────── if nightscoutEnabled { Section("Treatments") { + Toggle("Show IOB/COB History", isOn: $showIOBCOBHistory.value) + .onChange(of: showIOBCOBHistory.value) { _ in markDirty() } Toggle("Show Carb/Bolus Values", isOn: $showValues.value) .onChange(of: showValues.value) { _ in markDirty() } Toggle("Show Carb Absorption", isOn: $showAbsorption.value) diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 38fb991fb..258286df6 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -179,6 +179,7 @@ enum SettingsRoute: Hashable, Identifiable { SettingsLeaf("Show Carb/Bolus Values", ["carbs"]), SettingsLeaf("Show Carb Absorption"), SettingsLeaf("Treatments on Small Graph"), + SettingsLeaf("Show IOB/COB History", ["insulin", "carbs", "on board"]), SettingsLeaf("Small Graph Height", ["height"]), SettingsLeaf("Hours of Prediction", ["prediction"]), SettingsLeaf("Prediction Style"), diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..edaaa5585 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -129,6 +129,7 @@ class Storage { var show90MinLine = StorageValue(key: "show90MinLine", defaultValue: false) var showMidnightLines = StorageValue(key: "showMidnightMarkers", defaultValue: false) var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) + var showIOBCOBHistory = StorageValue(key: "showIOBCOBHistory", defaultValue: true) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) var smallGraphHeight = StorageValue(key: "smallGraphHeight", defaultValue: 40) diff --git a/LoopFollow/Task/DeviceStatusTask.swift b/LoopFollow/Task/DeviceStatusTask.swift index 412ffbf0f..5ada831f4 100644 --- a/LoopFollow/Task/DeviceStatusTask.swift +++ b/LoopFollow/Task/DeviceStatusTask.swift @@ -19,6 +19,7 @@ extension MainViewController { // from a previous Loop/Trio source so it doesn't linger on the chart. clearLoopPredictionGraph() clearOpenAPSPredictionGraph() + clearDeviceStatusMetricHistory() TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(60)) return } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ed1ce880f..319a5d639 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -99,6 +99,13 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = [] var noteGraphData: [DataStructs.noteStruct] = [] var deviceBatteryData: [DataStructs.batteryStruct] = [] + var deviceStatusMetricHistory: [DeviceStatusMetricSample] = [] + var deviceStatusMetricHistoryLoadedDays = 0 + var isLoadingDeviceStatusMetricHistory = false + var deviceStatusMetricHistorySource = "" + var deviceStatusMetricHistoryDevice = "" + var deviceStatusMetricHistoryGeneration = 0 + var deviceStatusRequestGeneration = 0 var lastCalDate: Double = 0 var latestLoopStatusString = "" var latestCOB: CarbMetric? @@ -309,6 +316,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { Storage.shared.url.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in + self?.prepareDeviceStatusMetricHistorySource() self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) @@ -316,6 +324,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { Storage.shared.token.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in + self?.prepareDeviceStatusMetricHistorySource() self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) diff --git a/Tests/Charts/BGChartTapCandidateTests.swift b/Tests/Charts/BGChartTapCandidateTests.swift new file mode 100644 index 000000000..c0003cd86 --- /dev/null +++ b/Tests/Charts/BGChartTapCandidateTests.swift @@ -0,0 +1,47 @@ +// LoopFollow +// BGChartTapCandidateTests.swift + +@testable import LoopFollow +import Testing + +struct BGChartTapCandidateTests { + @Test("closest candidate wins regardless of collection order") + func closestCandidateWins() { + let candidates = [ + BGChartTapCandidate(value: "farther-first", distanceSquared: 20 * 20), + BGChartTapCandidate(value: "closest", distanceSquared: 4 * 4), + BGChartTapCandidate(value: "farther-last", distanceSquared: 12 * 12), + ] + + #expect(nearestBGChartTapCandidate(candidates, within: 30) == "closest") + } + + @Test("candidate exactly on the hit radius is included") + func radiusIsInclusive() { + let candidates = [ + BGChartTapCandidate(value: "edge", distanceSquared: 30 * 30), + ] + + #expect(nearestBGChartTapCandidate(candidates, within: 30) == "edge") + } + + @Test("candidates outside the hit radius are ignored") + func outsideRadiusIsIgnored() { + let candidates = [ + BGChartTapCandidate(value: "outside", distanceSquared: 30 * 30 + 0.01), + ] + + #expect(nearestBGChartTapCandidate(candidates, within: 30) == nil) + #expect(nearestBGChartTapCandidate([BGChartTapCandidate](), within: 30) == nil) + } + + @Test("equal-distance candidates preserve source ordering") + func equalDistancePreservesOrder() { + let candidates = [ + BGChartTapCandidate(value: "first", distanceSquared: 10 * 10), + BGChartTapCandidate(value: "second", distanceSquared: 10 * 10), + ] + + #expect(nearestBGChartTapCandidate(candidates, within: 30) == "first") + } +} diff --git a/Tests/Charts/OnBoardOverlayScaleTests.swift b/Tests/Charts/OnBoardOverlayScaleTests.swift new file mode 100644 index 000000000..79db57e19 --- /dev/null +++ b/Tests/Charts/OnBoardOverlayScaleTests.swift @@ -0,0 +1,85 @@ +// LoopFollow +// OnBoardOverlayScaleTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct OnBoardOverlayScaleTests { + @Test("lane stays within the chart fraction and below the low-BG line") + func laneCeiling() { + #expect(BGChartModel.onBoardLaneCeiling(maxBG: 250, lowLine: 70) == 55) + #expect(BGChartModel.onBoardLaneCeiling(maxBG: 400, lowLine: 70) == 63) + } + + @Test("IOB and COB normalize independently into the same lane") + func independentNormalization() { + let lane = 55.0 + let iob = BGChartModel.scaledOnBoardValue(2, maximum: 4, laneCeiling: lane) + let cob = BGChartModel.scaledOnBoardValue(50, maximum: 100, laneCeiling: lane) + + #expect(iob == 27.5) + #expect(cob == 27.5) + #expect(BGChartModel.scaledOnBoardValue(4, maximum: 4, laneCeiling: lane) == lane) + #expect(BGChartModel.scaledOnBoardValue(150, maximum: 100, laneCeiling: lane) == lane) + } + + @Test("negative and invalid values stay on the zero baseline") + func clampsToBaseline() { + #expect(BGChartModel.scaledOnBoardValue(-0.5, maximum: 4, laneCeiling: 55) == 0) + #expect(BGChartModel.scaledOnBoardValue(2, maximum: 0, laneCeiling: 55) == 0) + #expect(BGChartModel.scaledOnBoardValue(.infinity, maximum: 4, laneCeiling: 55) == 0) + } + + @Test("all-zero histories retain a finite normalization denominator") + func zeroHistoryMaximum() { + let points = [ + point(at: 0, value: 0), + point(at: 300, value: -0.25), + ] + + #expect(BGChartModel.onBoardMaximum(for: points) == 1) + } + + @Test("runs split only when a device-status gap exceeds twelve minutes") + func splitsRunsAtDataGaps() { + let points = [ + point(at: 0, value: 1), + point(at: 12 * 60, value: 0.8), + point(at: 24 * 60 + 1, value: 0.5), + ] + + let runs = BGChartModel.makeOnBoardRuns(points) + + #expect(runs.count == 2) + #expect(runs[0].points.count == 2) + #expect(runs[1].points == [points[2]]) + } + + @Test("nearest lookup is logarithmic and respects its tolerance") + func nearestLookup() { + let points = [ + point(at: 0, value: 1), + point(at: 300, value: 0.8), + point(at: 600, value: 0.5), + ] + + #expect(BGChartModel.nearestOnBoardPoint( + in: points, + to: Date(timeIntervalSince1970: 460), + tolerance: 180 + ) == points[2]) + #expect(BGChartModel.nearestOnBoardPoint( + in: points, + to: Date(timeIntervalSince1970: 1200), + tolerance: 180 + ) == nil) + } + + private func point(at timestamp: TimeInterval, value: Double) -> BGChartModel.OnBoardPoint { + BGChartModel.OnBoardPoint( + date: Date(timeIntervalSince1970: timestamp), + value: value + ) + } +} diff --git a/Tests/Nightscout/DeviceStatusMetricHistoryParserTests.swift b/Tests/Nightscout/DeviceStatusMetricHistoryParserTests.swift new file mode 100644 index 000000000..7072a6bc1 --- /dev/null +++ b/Tests/Nightscout/DeviceStatusMetricHistoryParserTests.swift @@ -0,0 +1,201 @@ +// LoopFollow +// DeviceStatusMetricHistoryParserTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct DeviceStatusMetricHistoryParserTests { + private let now = Date(timeIntervalSince1970: 1_786_450_200) // 2026-08-11 12:10:00Z + + @Test("Loop uses metric-native timestamps and preserves zero COB") + func parsesLoopMetrics() throws { + let payload = try entries( + """ + [{ + "created_at": "2026-08-11T12:05:00.000Z", + "loop": { + "timestamp": "2026-08-11T12:04:30Z", + "iob": {"iob": 1.25, "timestamp": "2026-08-11T12:00:00Z"}, + "cob": {"cob": 0, "timestamp": "2026-08-11T12:01:00Z"} + } + }] + """ + ) + + let samples = DeviceStatusMetricHistoryParser.samples(from: payload, now: now) + + #expect(samples.count == 2) + #expect(samples[0].date == date("2026-08-11T12:00:00Z")) + #expect(samples[0].iob == 1.25) + #expect(samples[0].cob == nil) + #expect(samples[1].date == date("2026-08-11T12:01:00Z")) + #expect(samples[1].cob == 0) + } + + @Test("Trio determination values win over the legacy IOB object") + func parsesTrioDetermination() throws { + let payload = try entries( + """ + [{ + "created_at": "2026-08-11T12:06:00Z", + "openaps": { + "iob": {"iob": 1.3, "time": "2026-08-11T12:04:00Z"}, + "suggested": { + "IOB": 1.4, + "COB": 34, + "deliverAt": "2026-08-11T12:05:00Z" + } + } + }] + """ + ) + + let samples = DeviceStatusMetricHistoryParser.samples(from: payload, now: now) + + #expect(samples == [ + DeviceStatusMetricSample( + date: date("2026-08-11T12:05:00Z"), + iob: 1.4, + cob: 34 + ), + ]) + } + + @Test("Trio falls back independently from suggested to enacted") + func fallsBackToEnacted() throws { + let payload = try entries( + """ + [{ + "created_at": "2026-08-11T12:06:00Z", + "openaps": { + "suggested": {"IOB": 2.0, "deliverAt": "2026-08-11T12:05:00Z"}, + "enacted": {"COB": 21, "timestamp": "2026-08-11T12:04:00Z"} + } + }] + """ + ) + + let samples = DeviceStatusMetricHistoryParser.samples(from: payload, now: now) + + #expect(samples.count == 2) + #expect(samples[0].cob == 21) + #expect(samples[1].iob == 2) + } + + @Test("Legacy OpenAPS IOB arrays and reason-only decimal COB are supported") + func parsesLegacyOpenAPS() throws { + let payload = try entries( + """ + [{ + "created_at": "2026-08-11T12:06:00Z", + "openaps": { + "iob": [ + {"iob": 9, "time": "2026-08-11T12:01:00Z"}, + {"iob": -0.25, "time": "2026-08-11T12:03:00Z"} + ], + "suggested": { + "reason": "COB: 18.5, Dev: 2", + "deliverAt": "2026-08-11T12:05:00Z" + } + } + }] + """ + ) + + let samples = DeviceStatusMetricHistoryParser.samples(from: payload, now: now) + + #expect(samples.count == 2) + #expect(samples[0].iob == -0.25) + #expect(samples[1].cob == 18.5) + } + + @Test("Newest upload wins when determinations carry the same timestamp") + func deduplicatesCarriedForwardDeterminations() throws { + let payload = try entries( + """ + [ + { + "created_at": "2026-08-11T12:10:00Z", + "openaps": {"suggested": {"COB": 30, "deliverAt": "2026-08-11T12:05:00Z"}} + }, + { + "created_at": "2026-08-11T12:06:00Z", + "openaps": {"suggested": {"COB": 29, "deliverAt": "2026-08-11T12:05:00Z"}} + } + ] + """ + ) + + let samples = DeviceStatusMetricHistoryParser.samples(from: payload, now: now) + + #expect(samples.count == 1) + #expect(samples[0].cob == 30) + } + + @Test("Determinations without their own timestamp do not become fresh history") + func ignoresUntimestampedDeterminations() throws { + let payload = try entries( + """ + [{ + "created_at": "2026-08-11T12:09:00Z", + "openaps": {"suggested": {"IOB": 1.2, "COB": 24}} + }] + """ + ) + + #expect(DeviceStatusMetricHistoryParser.samples(from: payload, now: now).isEmpty) + } + + @Test("JSON booleans are not treated as numeric on-board values") + func ignoresBooleanMetrics() throws { + let payload = try entries( + """ + [{ + "created_at": "2026-08-11T12:09:00Z", + "loop": {"iob": {"iob": true}, "cob": {"cob": false}} + }] + """ + ) + + #expect(DeviceStatusMetricHistoryParser.samples(from: payload, now: now).isEmpty) + } + + @Test("Offset ISO dates, millisecond dates, ordering, and cutoff filtering work") + func parsesDatesAndFiltersRange() throws { + let payload = try entries( + """ + [ + {"date": 1786450320000, "loop": {"iob": {"iob": 3}}}, + {"created_at": "2026-08-11T08:00:00-04:00", "loop": {"cob": {"cob": 15}}}, + {"created_at": "2026-08-11T10:00:00Z", "loop": {"cob": {"cob": 10}}}, + {"created_at": "2026-08-11T12:25:00Z", "loop": {"iob": {"iob": 4}}}, + {"created_at": "invalid", "loop": {"iob": {"iob": 5}}} + ] + """ + ) + let cutoff = date("2026-08-11T11:00:00Z") + + let samples = DeviceStatusMetricHistoryParser.samples( + from: payload, + cutoff: cutoff, + now: now + ) + + #expect(samples.count == 2) + #expect(samples[0].date == date("2026-08-11T12:00:00Z")) + #expect(samples[0].cob == 15) + #expect(samples[1].date == date("2026-08-11T12:12:00Z")) + #expect(samples[1].iob == 3) + } + + private func entries(_ json: String) throws -> [[String: AnyObject]] { + let object = try JSONSerialization.jsonObject(with: Data(json.utf8)) + return try #require(object as? [[String: AnyObject]]) + } + + private func date(_ string: String) -> Date { + let formatter = ISO8601DateFormatter() + return formatter.date(from: string)! + } +} From 177e4d591989626de17ca883308c2c3a3f3cb4de Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:32:27 -0400 Subject: [PATCH 2/3] Stack and reposition IOB and COB legend --- LoopFollow/Charts/BGChartView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 03a06909b..03c5f5f23 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -351,8 +351,8 @@ private struct MainBGChart: View { model.currentIOB != nil || model.currentCOB != nil { OnBoardHistoryLegend(iob: model.currentIOB, cob: model.currentCOB) - .padding(.trailing, 42) - .padding(.bottom, max(viewport.height - plotFrame.maxY + 4, 4)) + .padding(.trailing, 4) + .padding(.bottom, max(viewport.height - plotFrame.maxY + 14, 14)) .frame( width: max(viewport.width, 1), height: max(viewport.height, 1), @@ -1952,7 +1952,7 @@ private struct OnBoardHistoryLegend: View { let cob: Double? var body: some View { - HStack(spacing: 7) { + VStack(alignment: .leading, spacing: 1) { if let iob { item( color: Color("Insulin"), From 818ea6e160c4fb254d10fb4d0df8e51ef504bedf Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:18:43 -0400 Subject: [PATCH 3/3] Keep chart taps working after foregrounding --- LoopFollow/Charts/BGChartView.swift | 27 +++++++++++++++------ Tests/Charts/BGChartTapCandidateTests.swift | 18 ++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 03c5f5f23..09e8c2f34 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -90,6 +90,14 @@ private func chartYDomainUpperBound(_ maxBG: Double) -> Double { return clampedMax + topPadding } +/// Keeps the last usable plot geometry while SwiftUI remounts the gesture +/// subtree. Preference propagation may briefly report `.zero` during that +/// transition; accepting it would make every tap fail its geometry guard. +func retainedBGChartPlotFrame(current: CGRect, incoming: CGRect) -> CGRect { + guard incoming.width > 0, incoming.height > 0 else { return current } + return incoming +} + struct BGChartView: View { enum Config { case small @@ -104,12 +112,17 @@ struct BGChartView: View { /// attachments while BGChartInteraction preserves the viewport. @State private var gestureMountEpoch = 0 + /// Plot geometry must outlive the gesture-only remount above. If this + /// state lives in MainBGChart, foregrounding resets it to zero and all + /// mark taps are discarded until the app is relaunched. + @State private var plotFrame: CGRect = .zero + var body: some View { Group { if config == .small { SmallBGChart(model: model, interaction: model.interaction) } else { - MainBGChart(model: model, interaction: model.interaction) + MainBGChart(model: model, interaction: model.interaction, plotFrame: $plotFrame) } } .id(gestureMountEpoch) @@ -137,6 +150,7 @@ struct BGChartView: View { private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction + @Binding private var plotFrame: CGRect /// Rendered slice of the domain. The canvas covers only this window /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width @@ -144,9 +158,10 @@ private struct MainBGChart: View { @State private var renderWindowStart: Date @State private var renderWindowEnd: Date - init(model: BGChartModel, interaction: BGChartInteraction) { + init(model: BGChartModel, interaction: BGChartInteraction, plotFrame: Binding) { _model = ObservedObject(wrappedValue: model) _interaction = ObservedObject(wrappedValue: interaction) + _plotFrame = plotFrame // Seed the render window around the current viewport so a remount's // first frame draws in place. let pad = BGChartConfig.renderWindowPadFactor * interaction.visibleSeconds @@ -154,10 +169,6 @@ private struct MainBGChart: View { _renderWindowEnd = State(initialValue: interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds + pad)) } - /// Plot area of the static axis overlay, in shell coordinates. The - /// selection overlay uses it for its value-to-pixel maps. - @State private var plotFrame: CGRect = .zero - /// Measured size of the visible selection pill (see PillSizePreferenceKey). @State private var pillSize: CGSize = .zero @@ -292,7 +303,9 @@ private struct MainBGChart: View { } } ) - .onPreferenceChange(PlotFramePreferenceKey.self) { plotFrame = $0 } + .onPreferenceChange(PlotFramePreferenceKey.self) { + plotFrame = retainedBGChartPlotFrame(current: plotFrame, incoming: $0) + } .onPreferenceChange(PillSizePreferenceKey.self) { pillSize = $0 } .onChange(of: interaction.scrollPosition) { _, _ in updateRenderWindow() diff --git a/Tests/Charts/BGChartTapCandidateTests.swift b/Tests/Charts/BGChartTapCandidateTests.swift index c0003cd86..98c1b9ca7 100644 --- a/Tests/Charts/BGChartTapCandidateTests.swift +++ b/Tests/Charts/BGChartTapCandidateTests.swift @@ -1,6 +1,7 @@ // LoopFollow // BGChartTapCandidateTests.swift +import CoreGraphics @testable import LoopFollow import Testing @@ -45,3 +46,20 @@ struct BGChartTapCandidateTests { #expect(nearestBGChartTapCandidate(candidates, within: 30) == "first") } } + +struct BGChartLifecycleTests { + @Test("foreground remount does not erase valid plot geometry") + func zeroFrameIsIgnored() { + let valid = CGRect(x: 34, y: 0, width: 589, height: 279) + + #expect(retainedBGChartPlotFrame(current: valid, incoming: .zero) == valid) + } + + @Test("new valid plot geometry replaces the retained frame") + func validFrameIsUpdated() { + let old = CGRect(x: 34, y: 0, width: 589, height: 279) + let resized = CGRect(x: 28, y: 0, width: 700, height: 320) + + #expect(retainedBGChartPlotFrame(current: old, incoming: resized) == resized) + } +}