Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion LoopFollow/Controllers/Nightscout/ProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
29 changes: 14 additions & 15 deletions LoopFollow/Controllers/Nightscout/Treatments/Basals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions Tests/ScheduledBasalSegmentsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// LoopFollow
// ScheduledBasalSegmentsTests.swift

import Foundation
@testable import LoopFollow
import Testing

@Suite(.serialized)
struct ScheduledBasalSegmentsTests {
private let schedule: [ProfileManager.TimeValue<Double>] = [
.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])
}
}
}