From 6dbc4898145a71ab7d504596449e725901c2b663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 29 Oct 2026 09:06:34 +0100 Subject: [PATCH] Use current scheduled basal after the last temp basal ends When no temp basal is running, the Basal info value is the scheduled rate at the current time, and the basal graph from the end of the last temp basal follows the schedule in the profile time zone, stepping at each schedule entry. --- .../Nightscout/ProfileManager.swift | 39 +++++++++- .../Nightscout/Treatments/Basals.swift | 29 ++++---- Tests/ScheduledBasalSegmentsTests.swift | 73 +++++++++++++++++++ 3 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 Tests/ScheduledBasalSegmentsTests.swift diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 13134f8b6..bb388ae05 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -151,11 +151,48 @@ final class ProfileManager { } func basal(at date: Date) -> String? { - value(from: basalSchedule, at: date).map { + basalRate(at: date).map { Localizer.formatToLocalizedString($0, maxFractionDigits: 2, minFractionDigits: 0) } } + func basalRate(at date: Date) -> Double? { + value(from: basalSchedule, at: date) + } + + /// Scheduled basal from `start` to `end`, one segment per schedule entry crossed. + func scheduledBasalSegments(from start: Date, to end: Date) -> [(start: Date, rate: Double)] { + guard start < end, let firstRate = basalRate(at: start) else { return [] } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timezone + + var boundaries: [Date] = [] + var day = calendar.startOfDay(for: start) + while day < end { + for entry in basalSchedule { + guard let boundary = calendar.date(bySettingHour: entry.timeAsSeconds / 3600, + minute: entry.timeAsSeconds % 3600 / 60, + second: entry.timeAsSeconds % 60, + of: day) + else { continue } + if boundary > start, boundary < end { + boundaries.append(boundary) + } + } + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = nextDay + } + + var segments = [(start: start, rate: firstRate)] + for boundary in boundaries.sorted() { + if let rate = basalRate(at: boundary), rate != segments.last?.rate { + segments.append((start: boundary, rate: rate)) + } + } + return segments + } + func carbRatio(at date: Date) -> Double? { value(from: carbRatioSchedule, at: date) } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 405281926..0359d0336 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -114,27 +114,26 @@ extension MainViewController { lastEndDot = lastDot } - // If last basal was prior to right now, we need to create one last scheduled entry + // If last basal was prior to right now, fill up to now with the scheduled basal if lastEndDot <= dateTimeUtils.getNowTimeIntervalUTC() { - var scheduled = 0.0 - for b in 0 ..< basalProfile.count { - let scheduleTimeToday = basalProfile[b].timeAsSeconds - + dateTimeUtils.getTimeIntervalMidnightToday() - if lastEndDot >= scheduleTimeToday { - scheduled = basalProfile[b].value - } - } + let now = Date() + let tailStart = Date(timeIntervalSince1970: max(lastEndDot, dateTimeUtils.getTimeIntervalNHoursAgo(N: 24 * Storage.shared.downloadDays.value))) + let tailEnd = now.addingTimeInterval(60 * 10) - latestBasal = Localizer.formatToLocalizedString(scheduled, + latestBasal = Localizer.formatToLocalizedString(profileManager.basalRate(at: now) ?? 0.0, maxFractionDigits: 2, minFractionDigits: 0) - let startDot = basalGraphStruct(basalRate: scheduled, date: lastEndDot) - basalData.append(startDot) + var segments = profileManager.scheduledBasalSegments(from: tailStart, to: tailEnd) + if segments.isEmpty { + segments = [(start: tailStart, rate: 0.0)] + } - let endDot = basalGraphStruct(basalRate: scheduled, - date: Date().timeIntervalSince1970 + (60 * 10)) - basalData.append(endDot) + for (index, segment) in segments.enumerated() { + let segmentEnd = index < segments.count - 1 ? segments[index + 1].start : tailEnd + basalData.append(basalGraphStruct(basalRate: segment.rate, date: segment.start.timeIntervalSince1970)) + basalData.append(basalGraphStruct(basalRate: segment.rate, date: segmentEnd.timeIntervalSince1970)) + } } if Storage.shared.graphBasal.value { diff --git a/Tests/ScheduledBasalSegmentsTests.swift b/Tests/ScheduledBasalSegmentsTests.swift new file mode 100644 index 000000000..2d70c8029 --- /dev/null +++ b/Tests/ScheduledBasalSegmentsTests.swift @@ -0,0 +1,73 @@ +// LoopFollow +// ScheduledBasalSegmentsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct ScheduledBasalSegmentsTests { + private let schedule: [ProfileManager.TimeValue] = [ + .init(timeAsSeconds: 0, value: 0.2), + .init(timeAsSeconds: 4 * 3600, value: 0.2), + .init(timeAsSeconds: 6 * 3600 + 1800, value: 0.6), + .init(timeAsSeconds: 14 * 3600 + 1800, value: 0.65), + .init(timeAsSeconds: 16 * 3600 + 1800, value: 0.5), + .init(timeAsSeconds: 20 * 3600 + 1800, value: 0.25), + ] + + private func withSchedule(_ body: (ProfileManager) -> Void) { + let manager = ProfileManager.shared + let savedSchedule = manager.basalSchedule + let savedTimeZone = manager.timezone + defer { + manager.basalSchedule = savedSchedule + manager.timezone = savedTimeZone + } + manager.basalSchedule = schedule + manager.timezone = TimeZone(secondsFromGMT: 3 * 3600)! + body(manager) + } + + private func utc(_ string: String) -> Date { + ISO8601DateFormatter().date(from: string)! + } + + @Test("splits at a schedule entry between start and end") + func splitsAtScheduleEntry() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T13:27:55Z"), to: utc("2026-10-28T13:48:00Z")) + #expect(segments.map(\.rate) == [0.65, 0.5]) + #expect(segments.map(\.start) == [utc("2026-10-28T13:27:55Z"), utc("2026-10-28T13:30:00Z")]) + #expect(manager.basalRate(at: utc("2026-10-28T13:38:00Z")) == 0.5) + } + } + + @Test("splits at midnight in the profile time zone") + func splitsAtMidnight() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T20:58:00Z"), to: utc("2026-10-28T21:10:00Z")) + #expect(segments.map(\.rate) == [0.25, 0.2]) + #expect(segments.map(\.start) == [utc("2026-10-28T20:58:00Z"), utc("2026-10-28T21:00:00Z")]) + } + } + + @Test("splits at the schedule's clock time on a daylight saving change day") + func splitsAtClockTimeAcrossDST() { + withSchedule { manager in + manager.timezone = TimeZone(identifier: "Europe/Stockholm")! + // 2026-10-25 falls back from UTC+2 to UTC+1 at 03:00, so 16:30 local is 15:30Z + let segments = manager.scheduledBasalSegments(from: utc("2026-10-25T15:00:00Z"), to: utc("2026-10-25T16:00:00Z")) + #expect(segments.map(\.rate) == [0.65, 0.5]) + #expect(segments.map(\.start) == [utc("2026-10-25T15:00:00Z"), utc("2026-10-25T15:30:00Z")]) + } + } + + @Test("skips entries that keep the same rate") + func skipsUnchangedRate() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T00:00:00Z"), to: utc("2026-10-28T02:00:00Z")) + #expect(segments.map(\.rate) == [0.2]) + } + } +}