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]) + } + } +}