From 6079f535f387e4449730bc2cc15c7db76ed79c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 17 Oct 2026 13:57:42 +0200 Subject: [PATCH] Open treatment details from a chart pill A single tap on a chart mark shows its pill as before. If the mark is in the Treatments list (carbs, boluses, SMBs, overrides, temp targets) the pill shows a chevron, and tapping the pill opens the treatment's details. Double-tap only zooms. The chart and the Treatments list build their entries with the same Treatment.historyEntries, so the pill opens the entry from the Nightscout treatment that drew the mark. --- LoopFollow/Charts/BGChartModel.swift | 23 +- LoopFollow/Charts/BGChartView.swift | 170 ++++---- LoopFollow/Controllers/NightScout.swift | 9 +- .../Nightscout/Treatments/Bolus.swift | 3 +- .../Nightscout/Treatments/Carbs.swift | 2 +- .../Nightscout/Treatments/Overrides.swift | 3 +- .../Nightscout/Treatments/SMB.swift | 3 +- .../Treatments/TemporaryTarget.swift | 3 +- LoopFollow/Helpers/DataStructs.swift | 8 +- LoopFollow/Treatments/CarbTreatment.swift | 30 -- LoopFollow/Treatments/TreatmentsView.swift | 375 +++++++++--------- Tests/TreatmentHistoryEntriesTests.swift | 58 +++ 12 files changed, 384 insertions(+), 303 deletions(-) delete mode 100644 LoopFollow/Treatments/CarbTreatment.swift create mode 100644 Tests/TreatmentHistoryEntriesTests.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index a09906be3..f0bf42859 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -54,19 +54,20 @@ final class BGChartModel: ObservableObject { let sgv: Double let label: String let pillText: String - let treatment: CarbTreatment? + /// The Treatments list entry the pill links to; nil for marks the list doesn't show. + let detail: Treatment? /// Where the symbol is drawn. Equals `date` unless `spread` nudged it /// left to keep a crowded run of treatments from stacking up. var drawnDate: Date var id: Double { date.timeIntervalSince1970 } - init(date: Date, value: Double, sgv: Double, label: String, pillText: String, treatment: CarbTreatment? = nil) { + init(date: Date, value: Double, sgv: Double, label: String, pillText: String, detail: Treatment? = nil) { self.date = date self.value = value self.sgv = sgv self.label = label self.pillText = pillText - self.treatment = treatment + self.detail = detail drawnDate = date } } @@ -91,6 +92,8 @@ final class BGChartModel: ObservableObject { let yTop: Double let label: String let pillText: String + /// The Treatments list entry the pill links to. + var detail: Treatment? = nil var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" } } @@ -469,7 +472,8 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: dose, - pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } carbs = Self.spread((showCarbs ? vc.carbData : []).map { @@ -484,7 +488,7 @@ final class BGChartModel: ObservableObject { sgv: Double($0.sgv), label: label, pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", - treatment: $0.treatment + detail: $0.detail ) }, minGap: Spread.carbGap, maxShift: Spread.carbShift) let smbPoints = (showBolus ? vc.smbData : []).map { @@ -494,7 +498,8 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: dose, - pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift) @@ -559,7 +564,8 @@ final class BGChartModel: ObservableObject { yBottom: yBottom, yTop: yTop, label: displayName, - pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } tempTargets = (showOtherTreatments ? vc.tempTargetGraphData : []).map { @@ -573,7 +579,8 @@ final class BGChartModel: ObservableObject { yBottom: yCenter - 5, yTop: yCenter + 5, label: "Temp Target", - pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 50a64b129..b35663863 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -46,6 +46,8 @@ private enum BGChartConfig { static let autoFollowPause: TimeInterval = 5 * 60 /// Screen-space radius (pt) within which a tap selects a mark. static let tapHitRadius: CGFloat = 30 + /// How far (pt) outside a linked pill a tap still opens its treatment. + static let pillLinkSlop: CGFloat = 8 } /// For line/area series: the items inside the window plus one beyond each @@ -83,7 +85,7 @@ struct BGChartView: View { /// attachments while BGChartInteraction preserves the viewport. @State private var gestureMountEpoch = 0 - /// Carb entry opened by a double-tap on the main chart. Owned here, outside + /// Treatment opened from a tapped pill on the main chart. Owned here, outside /// the remounted subtree, so the sheet survives a foregrounding remount. @State private var selectedTreatment: Treatment? @@ -112,7 +114,7 @@ struct BGChartView: View { } private func rootMeal(for treatment: Treatment) -> Treatment? { - treatment.rootMeal(in: Treatment.rootMealsByFPUID(model.carbs.compactMap { $0.treatment?.detailTreatment })) + treatment.rootMeal(in: Treatment.rootMealsByFPUID(model.carbs.compactMap(\.detail))) } } @@ -129,13 +131,14 @@ struct BGChartView: View { /// whose live preview is a `.scaleEffect(x:)` stretch anchored under the /// pinch centroid, committed on a geometric zoom grid. A one-finger press /// held stationary latches into inspect mode and scrubs a selection that is -/// rendered by a shell overlay (never re-laying the canvas). Double-tap on a -/// carb mark opens its details; elsewhere it cycles zoom presets. +/// rendered by a shell overlay (never re-laying the canvas). A tap on a mark +/// shows its pill, and a tap on a pill that links to the Treatments list opens +/// that treatment's details. Double-tap cycles zoom presets. /// No `.chartScrollableAxes`, no UIKit gesture hacks. private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction - /// Carb entry to show in the detail sheet; the shell owns the sheet. + /// Treatment to show in the detail sheet; the shell owns the sheet. @Binding var selectedTreatment: Treatment? /// Rendered slice of the domain. The canvas covers only this window @@ -319,15 +322,15 @@ private struct MainBGChart: View { .contentShape(Rectangle()) .simultaneousGesture(panAndInspectGesture(viewportWidth: viewportWidth)) .simultaneousGesture(magnifyGesture(viewportWidth: viewportWidth)) - // Double-tap opens carb treatment details, otherwise zooms. Single-tap - // waits for the double-tap window before selecting a mark. + // Double-tap zooms. Single-tap waits for the double-tap window before + // selecting a mark or following a pill's link. .simultaneousGesture( SpatialTapGesture(count: 2) .exclusively(before: SpatialTapGesture()) .onEnded { value in switch value { - case let .first(tap): - handleDoubleTap(at: tap.location, viewportWidth: viewportWidth) + case .first: + cycleZoomPreset() case let .second(tap): handleTap(at: tap.location, viewportWidth: viewportWidth, mainHeight: mainHeight) } @@ -795,6 +798,8 @@ private struct MainBGChart: View { let texts: [String] /// The BG reading under the selector, whose forecast can be shown. var readingDate: Date? + /// The Treatments list entry a tap on the pill opens. + var detail: Treatment? } /// Feeds every treatment mark to `body`. Single source for both the scrub @@ -831,18 +836,30 @@ private struct MainBGChart: View { return texts } + /// Treatments list entry of a band at `date`, the override's before the + /// temp target's (the order bandPillTexts lists them in). + private func bandDetail(at date: Date) -> Treatment? { + let override = model.overrides + .filter { date >= $0.start && date <= $0.end } + .max { $0.start < $1.start } + let tempTarget = model.tempTargets + .filter { date >= $0.start && date <= $0.end } + .max { $0.start < $1.start } + return override?.detail ?? tempTarget?.detail + } + /// Band (override / temp target) under the given date+value, if any. private func bandAnchor(at date: Date, value: Double) -> SelectionAnchor? { 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: [band.pillText], detail: band.detail) } } 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: [band.pillText], detail: band.detail) } } return nil @@ -906,17 +923,17 @@ private struct MainBGChart: View { var best: SelectionAnchor? var bestDistance2 = radius * radius - func consider(_ date: Date, _ value: Double, _ text: String, isReading: Bool = false) { + func consider(_ date: Date, _ value: Double, _ text: String, isReading: Bool = false, detail: Treatment? = nil) { 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], readingDate: isReading ? date : nil) + best = SelectionAnchor(date: date, value: value, texts: [text], readingDate: isReading ? date : nil, detail: detail) } } - forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText) } + forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText, detail: $0.detail) } if best == nil { for p in model.bg { consider(p.date, p.value, bgPillText(for: p), isReading: true) @@ -930,15 +947,28 @@ private struct MainBGChart: View { } if let best { let texts = best.texts + bandPillTexts(at: best.date) - return SelectionAnchor(date: best.date, value: best.value, texts: texts, readingDate: best.readingDate) + let detail = best.detail ?? bandDetail(at: best.date) + return SelectionAnchor(date: best.date, value: best.value, texts: texts, readingDate: best.readingDate, detail: detail) } return nil } - /// Taps on the main chart hit-test its marks; taps on a history pane select - /// by time alone, like a scrub. + /// A tap on a linked pill opens its treatment. Otherwise taps on the main + /// chart hit-test its marks, and taps on a history pane select by time + /// alone, like a scrub. private func handleTap(at location: CGPoint, viewportWidth: CGFloat, mainHeight: CGFloat) { guard plotFrame.height > 0 else { return } + if let detail = tapped?.detail, + let frame = pillFrame(for: tapped, viewportWidth: viewportWidth), + frame.insetBy(dx: -BGChartConfig.pillLinkSlop, dy: -BGChartConfig.pillLinkSlop).contains(location) + { + momentumTask?.cancel() + momentumTask = nil + resetGestureState() + tapped = nil + selectedTreatment = detail + return + } if location.y > mainHeight { let date = interaction.scrollPosition.addingTimeInterval( interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) @@ -956,39 +986,6 @@ private struct MainBGChart: View { return min(preferred, viewportHeight * 0.45 / CGFloat(count)) } - /// Double-tap hit test (screen-space, 2D) over carb marks only, at their - /// drawn (decluttered) positions. Returns nil when no carb is under the - /// finger — which cycles the zoom preset. - private func tappedCarb(at location: CGPoint, viewportWidth: CGFloat) -> BGChartModel.TreatmentPoint? { - let radius = BGChartConfig.tapHitRadius - var best: BGChartModel.TreatmentPoint? - var bestDistance2 = radius * radius - - for point in model.carbs { - let dx = xPosition(for: point.drawnDate, viewportWidth: viewportWidth) - location.x - let dy = yPosition(forValue: point.sgv) - location.y - let d2 = dx * dx + dy * dy - if d2 <= bestDistance2 { - bestDistance2 = d2 - best = point - } - } - return best - } - - private func handleDoubleTap(at location: CGPoint, viewportWidth: CGFloat) { - // A carb mark without AID metadata (no remote handle) has no details to show. - guard plotFrame.height > 0, let carb = tappedCarb(at: location, viewportWidth: viewportWidth)?.treatment else { - cycleZoomPreset() - return - } - momentumTask?.cancel() - momentumTask = nil - resetGestureState() - tapped = nil - selectedTreatment = carb.detailTreatment - } - /// The anchor the overlay should show: a live scrub wins over a sticky tap. private func activeAnchor() -> SelectionAnchor? { if isInspectLatched, let selected = selection { @@ -1047,6 +1044,20 @@ private struct MainBGChart: View { } } + /// Screen frame of the pill for `anchor`, or nil when no pill is drawn. + /// Shared by the overlay and the tap that follows a pill's link. + private func pillFrame(for anchor: SelectionAnchor?, viewportWidth: CGFloat) -> CGRect? { + guard plotFrame.height > 0, let anchor, !anchor.texts.isEmpty else { return nil } + let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) + guard x >= 0, x <= viewportWidth else { return nil } + // Measured size lags the text by one frame; fall back to a + // small nominal size until the first measurement lands. + let pillW = max(pillSize.width, 60) + let pillH = max(pillSize.height, 28) + let center = pillPosition(x: x, y: yPosition(forValue: anchor.value), pillW: pillW, pillH: pillH, viewportWidth: viewportWidth) + return CGRect(x: center.x - pillW / 2, y: center.y - pillH / 2, width: pillW, height: pillH) + } + /// Where the pill goes for an indicator at (x, y): under the anchor when /// there is room, else above it. private func pillPosition(x: CGFloat, y: CGFloat, pillW: CGFloat, pillH: CGFloat, viewportWidth: CGFloat) -> CGPoint { @@ -1106,7 +1117,6 @@ private struct MainBGChart: View { if plotFrame.height > 0, let anchor = activeAnchor() { let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) if x >= 0, x <= viewportWidth { - let y = yPosition(forValue: anchor.value) // With history panes the indicator runs down through them too. let lineBottom = model.historyPanes.isEmpty ? plotFrame.maxY : indicatorBottom @@ -1115,14 +1125,9 @@ private struct MainBGChart: View { .frame(width: 1, height: lineBottom - plotFrame.minY) .position(x: x, y: (plotFrame.minY + lineBottom) / 2) - // Measured size lags the text by one frame; fall back to a - // small nominal size until the first measurement lands. - let pillW = max(pillSize.width, 60) - let pillH = max(pillSize.height, 28) - if !anchor.texts.isEmpty { - let position = pillPosition(x: x, y: y, pillW: pillW, pillH: pillH, viewportWidth: viewportWidth) - PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16)) - .position(x: position.x, y: position.y) + if let frame = pillFrame(for: anchor, viewportWidth: viewportWidth) { + PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16), showsLink: anchor.detail != nil) + .position(x: frame.midX, y: frame.midY) } } } @@ -1848,27 +1853,36 @@ private struct PillLabel: View { /// readable over a busy cluster. let texts: [String] let maxWidth: CGFloat + /// The pill opens a Treatments list entry when tapped. + var showsLink = false var body: some View { - content - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(.secondarySystemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.primary, lineWidth: 0.5) - ) - ) - .background( - GeometryReader { geo in - Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) - } - ) - // Transparent flexible container: it caps the width the text can - // wrap to, while the visible pill above still hugs its content. - .frame(maxWidth: maxWidth) + HStack(spacing: 4) { + content + if showsLink { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundColor(.accentColor) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(.secondarySystemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(showsLink ? Color.accentColor : Color.primary, lineWidth: showsLink ? 1 : 0.5) + ) + ) + .background( + GeometryReader { geo in + Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) + } + ) + // Transparent flexible container: it caps the width the text can + // wrap to, while the visible pill above still hugs its content. + .frame(maxWidth: maxWidth) } @ViewBuilder diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 34a368ab1..2dcfe5a7e 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -31,19 +31,22 @@ extension MainViewController { } // NS Bolus Data Struct - struct bolusGraphStruct: Codable { + struct bolusGraphStruct { var value: Double var date: TimeInterval var sgv: Int + /// The Treatments list entry this dot opens. + var detail: Treatment? = nil } // NS Bolus Data Struct - struct carbGraphStruct: Codable { + struct carbGraphStruct { var value: Double var date: TimeInterval var sgv: Int var absorptionTime: Int - var treatment: CarbTreatment? = nil + /// The Treatments list entry this dot opens. + var detail: Treatment? = nil } func clearOldTempBasal() { diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift index 9fe4ba7b1..1c8a4c160 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift @@ -20,7 +20,8 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { // Make the dot - let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + let detail = Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.bolus + let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20), detail: detail) bolusData.append(dot) } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 82d05714c..43a1b2b9b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -40,7 +40,7 @@ extension MainViewController { date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime, - treatment: CarbTreatment(nightscoutEntry: currentEntry, date: dateTimeStamp) + detail: Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.carb ) carbData.append(dot) } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index 00dd745bd..fddc55200 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -84,7 +84,8 @@ extension MainViewController { reason: (e["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - sgv: -20 + sgv: -20, + detail: Treatment.historyEntries(from: e, date: startDate.timeIntervalSince1970) { 0 }.entries.first ) overrideGraphData.append(dot) diff --git a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift index 012bed356..466503e87 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift @@ -19,7 +19,8 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { // Make the dot - let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + let detail = Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.bolus + let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20), detail: detail) smbData.append(dot) } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index 592e47f7a..2222a230f 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -53,7 +53,8 @@ extension MainViewController { let endDate = dateTimeStamp + duration - let dot = DataStructs.tempTargetStruct(date: dateTimeStamp, endDate: endDate, duration: duration, correctionRange: [Int(targetValue!)], enteredBy: enteredBy, reason: reason) + let detail = Treatment.historyEntries(from: currentEntry, date: parsedDate.timeIntervalSince1970) { 0 }.entries.first + let dot = DataStructs.tempTargetStruct(date: dateTimeStamp, endDate: endDate, duration: duration, correctionRange: [Int(targetValue!)], enteredBy: enteredBy, reason: reason, detail: detail) tempTargetGraphData.append(dot) // Set activeTempTarget only if it is still active diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index 9cd8a175f..d6330a536 100644 --- a/LoopFollow/Helpers/DataStructs.swift +++ b/LoopFollow/Helpers/DataStructs.swift @@ -37,7 +37,7 @@ class DataStructs { } // NS Override Data Struct - struct overrideStruct: Codable { + struct overrideStruct { var insulNeedsScaleFactor: Double var date: TimeInterval var endDate: TimeInterval @@ -46,14 +46,18 @@ class DataStructs { var enteredBy: String var reason: String var sgv: Float + /// The Treatments list entry this band opens. + var detail: Treatment? = nil } - struct tempTargetStruct: Codable { + struct tempTargetStruct { var date: TimeInterval var endDate: TimeInterval var duration: Double var correctionRange: [Int] var enteredBy: String var reason: String + /// The Treatments list entry this band opens. + var detail: Treatment? = nil } } diff --git a/LoopFollow/Treatments/CarbTreatment.swift b/LoopFollow/Treatments/CarbTreatment.swift deleted file mode 100644 index 8a373cf61..000000000 --- a/LoopFollow/Treatments/CarbTreatment.swift +++ /dev/null @@ -1,30 +0,0 @@ -// LoopFollow -// CarbTreatment.swift - -import Foundation - -/// Source metadata for a carb mark, preserving the identifier used by its AID app. -enum CarbTreatment: Codable, Equatable { - case loop(LoopCarbTreatment) - case trio(TrioMealTreatment) - - init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) { - if let meal = TrioMealTreatment(nightscoutEntry: entry, date: date) { - self = .trio(meal) - } else if let carb = LoopCarbTreatment(nightscoutEntry: entry, date: date) { - self = .loop(carb) - } else { - return nil - } - } - - /// The existing detail screen owns all remote action availability and command handling. - var detailTreatment: Treatment { - switch self { - case let .loop(carb): - return .carb(nightscoutID: carb.nightscoutID, date: carb.date, carbs: carb.carbs, bgValue: 0, loopCarb: carb) - case let .trio(meal): - return .carb(nightscoutID: meal.nightscoutID, date: meal.date, carbs: meal.carbs, bgValue: 0, trioMeal: meal) - } - } -} diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index 7aea0415a..c2a91c208 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -1121,6 +1121,18 @@ enum TreatmentFilter: Hashable { } } +/// What the Treatments list shows for one Nightscout treatment. +struct TreatmentHistoryEntries { + var entries: [Treatment] = [] + /// The entry is an SMB, which picks the list's automatic filter label. + var sawSMB = false + /// The entry is flagged automatic, which picks the list's automatic filter label. + var sawAutomatic = false + + var carb: Treatment? { entries.first { $0.type == .carb } } + var bolus: Treatment? { entries.first { [.bolusManual, .bolusAutomatic, .smb].contains($0.type) } } +} + struct Treatment: Identifiable { let id: String let type: TreatmentType @@ -1182,6 +1194,187 @@ extension Treatment { return parts.compactMap { $0 }.joined(separator: " • ") } + /// The Treatments list entries for one Nightscout treatment. A Meal Bolus yields a carb entry and a bolus entry. + static func historyEntries(from entry: [String: AnyObject], date: TimeInterval, nearestBG: () -> Int) -> TreatmentHistoryEntries { + var result = TreatmentHistoryEntries() + guard let eventType = entry["eventType"] as? String else { return result } + let nsId = entry["_id"] as? String ?? "unknown-\(date)" + + switch eventType { + case "Carb Correction", "Meal Bolus": + let trioMeal = eventType == "Carb Correction" ? TrioMealTreatment(nightscoutEntry: entry, date: date) : nil + let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: date) : nil + let carbs = entry["carbs"] as? Double ?? 0 + if carbs > 0 || trioMeal != nil { + let actualBG = nearestBG() + let treatment = Treatment.carb( + nightscoutID: nsId, + date: date, + carbs: carbs, + bgValue: actualBG, + trioMeal: trioMeal, + loopCarb: loopCarb + ) + result.entries.append(treatment) + } + + // Also process bolus from Meal Bolus + if eventType == "Meal Bolus", + let insulin = entry["insulin"] as? Double, insulin > 0 + { + let actualBG = nearestBG() + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolusManual, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + + case "Correction Bolus", "Bolus", "External Insulin": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + let isAutomatic = entry["automatic"] as? Bool ?? false + let isSMB = entry["isSMB"] as? Bool ?? false + let typeString = (entry["type"] as? String)?.lowercased() + let isTrioSMBType = typeString == "smb" + let treatAsAutomatic = isAutomatic || isSMB || isTrioSMBType + let actualBG = nearestBG() + + if treatAsAutomatic { + result.sawSMB = isSMB || isTrioSMBType + result.sawAutomatic = isAutomatic + let treatment = Treatment( + id: "\(nsId)-smb", + type: .bolusAutomatic, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: isSMB || isTrioSMBType ? "SMB" : "Automatic Bolus", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } else { + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolusManual, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + } + + case "SMB": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + result.sawSMB = true + let actualBG = nearestBG() + let treatment = Treatment( + id: "\(nsId)-smb", + type: .smb, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "SMB", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + + case "Temp Basal": + if let rate = entry["rate"] as? Double { + let treatment = Treatment( + id: "\(nsId)-basal", + type: .tempBasal, + date: date, + title: String(format: "%.2f U/hr", rate), + subtitle: "Temp Basal", + icon: "chart.xyaxis.line", + color: .blue, + bgValue: 0 + ) + result.entries.append(treatment) + } + + case "Temporary Override", "Exercise": + let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) + if durationMinutes == 0 { + return result + } + + let reason = (entry["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? "" + + let subtitleParts = ["Override", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil].compactMap { $0 } + let subtitle = subtitleParts.joined(separator: " • ") + let title = reason.isEmpty ? "Temporary Override" : reason + + let treatment = Treatment( + id: "\(nsId)-override", + type: .override, + date: date, + title: title, + subtitle: subtitle, + icon: "slider.horizontal.3", + color: .purple, + bgValue: 0 + ) + result.entries.append(treatment) + + case "Temporary Target": + let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) + if durationMinutes == 0 { + return result + } + + let targetBottom = (entry["targetBottom"] as? Double) ?? Double(entry["targetBottom"] as? Int ?? 0) + let targetTop = (entry["targetTop"] as? Double) ?? Double(entry["targetTop"] as? Int ?? 0) + let targetValue = targetBottom > 0 ? targetBottom : targetTop + + guard targetValue > 0 else { + return result + } + + let title: String + if targetBottom > 0, targetTop > 0, abs(targetTop - targetBottom) >= 0.1 { + title = "\(formatBG(targetBottom)) - \(formatBG(targetTop))" + } else { + title = formatBG(targetValue) + } + + let reason = (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitleParts = ["Temp Target", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil, reason?.isEmpty == false ? reason : nil].compactMap { $0 } + let subtitle = subtitleParts.joined(separator: " • ") + + let treatment = Treatment( + id: "\(nsId)-temp-target", + type: .tempTarget, + date: date, + title: title, + subtitle: subtitle, + icon: "scope", + color: .green, + bgValue: 0 + ) + result.entries.append(treatment) + + default: + break + } + return result + } + /// Trio root meals keyed by the `fpuID` their FPU children carry. static func rootMealsByFPUID(_ treatments: some Sequence) -> [UUID: Treatment] { var roots: [UUID: Treatment] = [:] @@ -1391,7 +1584,7 @@ class TreatmentsViewModel: ObservableObject { guard let mainVC = getMainViewController() else { return ([], false, false) } for entry in entries { - guard let eventType = entry["eventType"] as? String, + guard entry["eventType"] is String, let createdAt = entry["created_at"] as? String, let date = NightscoutUtils.parseDate(createdAt) else { @@ -1409,182 +1602,10 @@ class TreatmentsViewModel: ObservableObject { // Mark this entry as processed processedNightscoutIds.insert(nsId) - switch eventType { - case "Carb Correction", "Meal Bolus": - let trioMeal = eventType == "Carb Correction" ? TrioMealTreatment(nightscoutEntry: entry, date: timestamp) : nil - let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: timestamp) : nil - let carbs = entry["carbs"] as? Double ?? 0 - if carbs > 0 || trioMeal != nil { - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment.carb( - nightscoutID: nsId, - date: timestamp, - carbs: carbs, - bgValue: actualBG, - trioMeal: trioMeal, - loopCarb: loopCarb - ) - treatments.append(treatment) - } - - // Also process bolus from Meal Bolus - if eventType == "Meal Bolus", - let insulin = entry["insulin"] as? Double, insulin > 0 - { - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-bolus", - type: .bolusManual, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "Bolus", - icon: "circle.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - - case "Correction Bolus", "Bolus", "External Insulin": - if let insulin = entry["insulin"] as? Double, insulin > 0 { - let isAutomatic = entry["automatic"] as? Bool ?? false - let isSMB = entry["isSMB"] as? Bool ?? false - let typeString = (entry["type"] as? String)?.lowercased() - let isTrioSMBType = typeString == "smb" - let treatAsAutomatic = isAutomatic || isSMB || isTrioSMBType - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - - if treatAsAutomatic { - if isSMB || isTrioSMBType { - detectedSMB = true - } - if isAutomatic { - detectedAutomatic = true - } - let treatment = Treatment( - id: "\(nsId)-smb", - type: .bolusAutomatic, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: isSMB || isTrioSMBType ? "SMB" : "Automatic Bolus", - icon: "arrowtriangle.down.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } else { - let treatment = Treatment( - id: "\(nsId)-bolus", - type: .bolusManual, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "Bolus", - icon: "circle.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - } - - case "SMB": - if let insulin = entry["insulin"] as? Double, insulin > 0 { - detectedSMB = true - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-smb", - type: .smb, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "SMB", - icon: "arrowtriangle.down.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - - case "Temp Basal": - if let rate = entry["rate"] as? Double { - let treatment = Treatment( - id: "\(nsId)-basal", - type: .tempBasal, - date: timestamp, - title: String(format: "%.2f U/hr", rate), - subtitle: "Temp Basal", - icon: "chart.xyaxis.line", - color: .blue, - bgValue: 0 - ) - treatments.append(treatment) - } - - case "Temporary Override", "Exercise": - let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) - if durationMinutes == 0 { - continue - } - - let reason = (entry["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - ?? (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - ?? "" - - let subtitleParts = ["Override", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil].compactMap { $0 } - let subtitle = subtitleParts.joined(separator: " • ") - let title = reason.isEmpty ? "Temporary Override" : reason - - let treatment = Treatment( - id: "\(nsId)-override", - type: .override, - date: timestamp, - title: title, - subtitle: subtitle, - icon: "slider.horizontal.3", - color: .purple, - bgValue: 0 - ) - treatments.append(treatment) - - case "Temporary Target": - let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) - if durationMinutes == 0 { - continue - } - - let targetBottom = (entry["targetBottom"] as? Double) ?? Double(entry["targetBottom"] as? Int ?? 0) - let targetTop = (entry["targetTop"] as? Double) ?? Double(entry["targetTop"] as? Int ?? 0) - let targetValue = targetBottom > 0 ? targetBottom : targetTop - - guard targetValue > 0 else { - continue - } - - let title: String - if targetBottom > 0, targetTop > 0, abs(targetTop - targetBottom) >= 0.1 { - title = "\(formatBG(targetBottom)) - \(formatBG(targetTop))" - } else { - title = formatBG(targetValue) - } - - let reason = (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let subtitleParts = ["Temp Target", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil, reason?.isEmpty == false ? reason : nil].compactMap { $0 } - let subtitle = subtitleParts.joined(separator: " • ") - - let treatment = Treatment( - id: "\(nsId)-temp-target", - type: .tempTarget, - date: timestamp, - title: title, - subtitle: subtitle, - icon: "scope", - color: .green, - bgValue: 0 - ) - treatments.append(treatment) - - default: - break - } + let history = Treatment.historyEntries(from: entry, date: timestamp) { findNearestBG(at: timestamp, in: mainVC.bgData) } + treatments.append(contentsOf: history.entries) + detectedSMB = detectedSMB || history.sawSMB + detectedAutomatic = detectedAutomatic || history.sawAutomatic } // Sort by date descending (most recent first) diff --git a/Tests/TreatmentHistoryEntriesTests.swift b/Tests/TreatmentHistoryEntriesTests.swift new file mode 100644 index 000000000..faf84b39d --- /dev/null +++ b/Tests/TreatmentHistoryEntriesTests.swift @@ -0,0 +1,58 @@ +// LoopFollow +// TreatmentHistoryEntriesTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TreatmentHistoryEntriesTests { + private typealias Entry = [String: AnyObject] + + private func history(_ fields: [String: Any]) -> TreatmentHistoryEntries { + var entry: Entry = ["_id": "ns1" as AnyObject] + for (key, value) in fields { + entry[key] = value as AnyObject + } + return Treatment.historyEntries(from: entry, date: 100) { 120 } + } + + @Test("a Meal Bolus yields a carb entry and a bolus entry") + func mealBolus() { + let result = history(["eventType": "Meal Bolus", "carbs": 30.0, "insulin": 2.5]) + #expect(result.entries.map(\.type) == [.carb, .bolusManual]) + #expect(result.carb?.id == "ns1-carb") + #expect(result.bolus?.id == "ns1-bolus") + } + + @Test("automatic and SMB boluses are listed as automatic") + func automaticBolus() { + let automatic = history(["eventType": "Correction Bolus", "insulin": 0.4, "automatic": true]) + #expect(automatic.bolus?.type == .bolusAutomatic) + #expect(automatic.sawAutomatic) + #expect(!automatic.sawSMB) + + let smb = history(["eventType": "Correction Bolus", "insulin": 0.4, "type": "SMB"]) + #expect(smb.bolus?.type == .bolusAutomatic) + #expect(smb.sawSMB) + #expect(!smb.sawAutomatic) + + let manual = history(["eventType": "Bolus", "insulin": 1.0]) + #expect(manual.bolus?.type == .bolusManual) + #expect(!manual.sawSMB && !manual.sawAutomatic) + } + + @Test("entries the Treatments list skips yield nothing") + func skipped() { + #expect(history(["eventType": "Carb Correction", "carbs": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Bolus", "insulin": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Temporary Override", "duration": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Temporary Target", "duration": 30.0]).entries.isEmpty) + #expect(history(["eventType": "Note", "notes": "hello"]).entries.isEmpty) + } + + @Test("overrides and temp targets with a duration are listed") + func bands() { + #expect(history(["eventType": "Temporary Override", "duration": 60.0, "reason": "Run"]).entries.first?.type == .override) + #expect(history(["eventType": "Temporary Target", "duration": 30.0, "targetBottom": 140.0, "targetTop": 140.0]).entries.first?.type == .tempTarget) + } +}