Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5604c91
Merge pull request #725 from loopandlearn/release/v7.0.0
marionbarker Jul 25, 2026
73aaf02
Fix Advanced Settings graph toggles not hiding chart elements (#729)
bjorkert Jul 29, 2026
d9f7b2a
CI: Bump dev version to 7.0.1 [skip ci]
github-actions[bot] Jul 29, 2026
639f8c6
Fix main graph locking up after system-cancelled gestures (#733)
bjorkert Jul 29, 2026
d9f5974
CI: Bump dev version to 7.0.2 [skip ci]
github-actions[bot] Jul 29, 2026
193029a
Refactor OverrideActivationModal to simplify duration handling (#740)
codebymini Aug 16, 2026
835689a
CI: Bump dev version to 7.0.3 [skip ci]
github-actions[bot] Aug 16, 2026
89c22ff
Fix missing Trio FPU entries (#741)
aug0211 Aug 26, 2026
a2ea308
CI: Bump dev version to 7.0.4 [skip ci]
github-actions[bot] Aug 26, 2026
bc1a973
Recover the Silent Tune keep-alive after audio interruptions (#745)
bjorkert Aug 26, 2026
48e21c4
CI: Bump dev version to 7.0.5 [skip ci]
github-actions[bot] Aug 26, 2026
1f39057
Add lines to graph and mini graph for "current time" on prior days (#…
aug0211 Sep 17, 2026
dc93d6f
CI: Bump dev version to 7.0.6 [skip ci]
github-actions[bot] Sep 17, 2026
f6d4ca6
Add App Intents to enable/disable BG speech via Shortcuts (#727)
skimaniac Sep 17, 2026
85ef02c
CI: Bump dev version to 7.0.7 [skip ci]
github-actions[bot] Sep 17, 2026
ea65ec1
CI: Move remaining actions to Node 24 runtimes (#752)
bjorkert Sep 18, 2026
6624aed
CI: Bump dev version to 7.0.8 [skip ci]
github-actions[bot] Sep 18, 2026
9a221c1
Carry the pump reservoir value across records that omit it (#746)
bjorkert Sep 20, 2026
48078d4
CI: Bump dev version to 7.0.9 [skip ci]
github-actions[bot] Sep 20, 2026
1939147
Clarify remote command sent confirmation message (#750)
codebymini Sep 20, 2026
9a1ec28
CI: Bump dev version to 7.0.10 [skip ci]
github-actions[bot] Sep 20, 2026
db1824d
Snap chart scrubbing to a five-minute grid locked to the readings (#748)
bjorkert Sep 20, 2026
9f6bfea
CI: Bump dev version to 7.0.11 [skip ci]
github-actions[bot] Sep 20, 2026
942cead
Zip log files before sharing (#747)
bjorkert Sep 20, 2026
3f68dc8
CI: Bump dev version to 7.0.12 [skip ci]
github-actions[bot] Sep 20, 2026
95db2aa
update version to 7.1.0
marionbarker Sep 20, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
uses: actions/checkout@v5

- name: Cache SwiftFormat build
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: BuildTools/.build
key: ${{ runner.os }}-swiftformat-${{ hashFiles('BuildTools/Package.resolved', 'BuildTools/Package.swift') }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/warn_main_pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:

steps:
- name: Comment on PR
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
Expand Down
2 changes: 1 addition & 1 deletion Config.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
unique_id = ${DEVELOPMENT_TEAM}

//Version (DEFAULT)
LOOP_FOLLOW_MARKETING_VERSION = 7.0.0
LOOP_FOLLOW_MARKETING_VERSION = 7.1.0
53 changes: 41 additions & 12 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ final class BGChartInteraction: ObservableObject {
/// when the user pans back into history, re-armed when they return to the edge.
@Published var followLatest: Bool = true

/// Set once the main chart has done its initial scroll-to-now; kept out of
/// view @State so a chart remount (see BGChartView) keeps the user's place.
var hasInitializedViewport = false

init() {
let seconds = Self.visibleSeconds(forScale: Storage.shared.chartScaleX.value)
visibleSeconds = seconds
Expand Down Expand Up @@ -107,6 +111,8 @@ final class BGChartModel: ObservableObject {
}

@Published var bg: [BGPoint] = []
/// Scrub timeline on the BG cadence; rebuilt together with `bg`.
private(set) var scrubSlots = BGChartScrubSlots(readingDates: [])
@Published var bgRuns: [BGRun] = []
@Published var yesterday: [BGPoint] = []
@Published var prediction: [BGPoint] = []
Expand Down Expand Up @@ -153,6 +159,7 @@ final class BGChartModel: ObservableObject {
@Published var now: Date = .init()
@Published var diaMarkers: [Date] = []
@Published var midnightMarkers: [Date] = []
@Published var priorDayTimeMarkers: [Date] = []
@Published var thirtyMinMark: Date? = nil
@Published var ninetyMinMark: Date? = nil

Expand All @@ -175,6 +182,7 @@ final class BGChartModel: ObservableObject {
@Published var show90Min: Bool = false
@Published var showMidnight: Bool = false
@Published var smallGraphTreatments: Bool = true
@Published var showPriorDayTime: Bool = false

private static let doseFormatter: NumberFormatter = {
let nf = NumberFormatter()
Expand Down Expand Up @@ -407,6 +415,15 @@ final class BGChartModel: ObservableObject {
show90Min = Storage.shared.show90MinLine.value
showMidnight = Storage.shared.showMidnightLines.value
smallGraphTreatments = Storage.shared.smallGraphTreatments.value
showPriorDayTime = Storage.shared.showPriorDayTimeLines.value

// Advanced-settings visibility toggles. The Nightscout controllers
// collect the data regardless (it also feeds the info rows), so hidden
// kinds are dropped here at render time.
let showBasal = Storage.shared.graphBasal.value
let showBolus = Storage.shared.graphBolus.value
let showCarbs = Storage.shared.graphCarbs.value
let showOtherTreatments = Storage.shared.graphOtherTreatments.value

let isLoop = Storage.shared.device.value == "Loop"
overrideColor = isLoop ? .green : .purple
Expand All @@ -418,6 +435,7 @@ final class BGChartModel: ObservableObject {
let maxDisplay = globalVariables.maxDisplayGlucose
func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) }

scrubSlots = BGChartScrubSlots(readingDates: vc.bgData.map { Date(timeIntervalSince1970: $0.date) })
bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) }
bgRuns = Self.makeRuns(bg)

Expand All @@ -436,7 +454,7 @@ 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) }

let bolusPoints = vc.bolusData.map {
let bolusPoints = (showBolus ? vc.bolusData : []).map {
let dose = self.formatDose($0.value)
return TreatmentPoint(
date: Date(timeIntervalSince1970: $0.date),
Expand All @@ -446,7 +464,7 @@ final class BGChartModel: ObservableObject {
pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
)
}
carbs = Self.spread(vc.carbData.map {
carbs = Self.spread((showCarbs ? vc.carbData : []).map {
let grams = Int($0.value)
var label = "\(grams)"
if $0.absorptionTime > 0, Storage.shared.showAbsorption.value {
Expand All @@ -460,7 +478,7 @@ final class BGChartModel: ObservableObject {
pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
)
}, minGap: Spread.carbGap, maxShift: Spread.carbShift)
let smbPoints = vc.smbData.map {
let smbPoints = (showBolus ? vc.smbData : []).map {
let dose = self.formatDose($0.value)
return TreatmentPoint(
date: Date(timeIntervalSince1970: $0.date),
Expand All @@ -471,7 +489,7 @@ final class BGChartModel: ObservableObject {
)
}
(boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift)
bgChecks = vc.bgCheckData.map {
bgChecks = (showOtherTreatments ? vc.bgCheckData : []).map {
TreatmentPoint(
date: Date(timeIntervalSince1970: $0.date),
value: Double($0.sgv),
Expand All @@ -480,16 +498,16 @@ final class BGChartModel: ObservableObject {
pillText: "BG Check\n\(Localizer.toDisplayUnits(String($0.sgv)))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
)
}
suspends = vc.suspendGraphData.map {
suspends = (showOtherTreatments ? vc.suspendGraphData : []).map {
TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Suspend\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
}
resumes = vc.resumeGraphData.map {
resumes = (showOtherTreatments ? vc.resumeGraphData : []).map {
TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Resume\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
}
sensorStarts = vc.sensorStartGraphData.map {
sensorStarts = (showOtherTreatments ? vc.sensorStartGraphData : []).map {
TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Sensor Start\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))")
}
notes = vc.noteGraphData.map {
notes = (showOtherTreatments ? vc.noteGraphData : []).map {
TreatmentPoint(
date: Date(timeIntervalSince1970: $0.date),
value: Double($0.sgv),
Expand All @@ -499,12 +517,12 @@ final class BGChartModel: ObservableObject {
)
}

basalScheduled = vc.basalScheduleData.map {
basalScheduled = (showBasal ? vc.basalScheduleData : []).map {
ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate)
}

var steps: [BasalStep] = []
let sortedBasal = vc.basalData.sorted { $0.date < $1.date }
let sortedBasal = (showBasal ? vc.basalData : []).sorted { $0.date < $1.date }
for i in 0 ..< sortedBasal.count {
let start = sortedBasal[i].date
let end = i + 1 < sortedBasal.count
Expand All @@ -523,7 +541,7 @@ final class BGChartModel: ObservableObject {

let yTop = maxBG - 5
let yBottom = maxBG - 25
overrides = vc.overrideGraphData.map {
overrides = (showOtherTreatments ? vc.overrideGraphData : []).map {
let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = overrideName.isEmpty ? "Override" : overrideName
return BandRect(
Expand All @@ -535,7 +553,7 @@ final class BGChartModel: ObservableObject {
pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
)
}
tempTargets = vc.tempTargetGraphData.map {
tempTargets = (showOtherTreatments ? vc.tempTargetGraphData : []).map {
let target = $0.correctionRange.first.map { String($0) } ?? ""
// Temp targets render at the BG level they target (±5 mg/dL);
// only overrides live in the top strip.
Expand Down Expand Up @@ -584,6 +602,17 @@ final class BGChartModel: ObservableObject {
}
return cal
}()

// Mark the current local time on each prior day. Calendar arithmetic
// preserves the displayed time of day across daylight-saving changes.
var priorDayTimes: [Date] = []
var priorDayTime = calendar.date(byAdding: .day, value: -1, to: currentNow)
while let marker = priorDayTime, marker > domainStart {
priorDayTimes.append(marker)
priorDayTime = calendar.date(byAdding: .day, value: -1, to: marker)
}
priorDayTimeMarkers = priorDayTimes

var cursor = calendar.startOfDay(for: domainStart)
while cursor <= domainEnd {
if cursor >= domainStart {
Expand Down
148 changes: 148 additions & 0 deletions LoopFollow/Charts/BGChartScrubSlots.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// LoopFollow
// BGChartScrubSlots.swift

import Foundation

/// Five-minute grid, phase-locked to the readings, that the scrub indicator
/// snaps to.
///
/// The grid is walked backwards from the newest reading. Each step lands on
/// the reading nearest the expected mark when one lies within half a cadence
/// of it, and on a virtual mark otherwise, so drift is absorbed and gaps are
/// crossed at exactly the cadence. The grid continues before the first and
/// after the last mark. Each mark owns the time between the midpoints to its
/// neighbours, so the blocks tile the timeline and every reading and
/// treatment belongs to exactly one mark.
struct BGChartScrubSlots {
struct Slot: Equatable {
let date: Date
/// Index into `readingDates` for a mark placed on a reading; nil for a
/// virtual mark.
let readingIndex: Int?
/// Owned time, inclusive at the start and exclusive at the end.
let blockStart: Date
let blockEnd: Date

var isReading: Bool { readingIndex != nil }

func contains(_ date: Date) -> Bool {
date >= blockStart && date < blockEnd
}
}

static let cadence: TimeInterval = 5 * 60

/// Every reading timestamp, ascending.
let readingDates: [Date]
/// Grid marks inside the data range, ascending.
private let slotDates: [Date]
private let slotReadingIndices: [Int?]

init(readingDates: [Date]) {
let sorted = readingDates.sorted()
self.readingDates = sorted

var dates: [Date] = []
var indices: [Int?] = []
if let first = sorted.first, let newest = sorted.last {
let cadence = Self.cadence
var cursor = newest
var cursorIndex = sorted.count - 1
dates.append(cursor)
indices.append(cursorIndex)
while first < cursor.addingTimeInterval(-cadence / 2) {
let target = cursor.addingTimeInterval(-cadence)
if let hit = Self.nearestReading(in: sorted, before: cursorIndex, to: target, within: cadence / 2) {
cursor = sorted[hit]
cursorIndex = hit
dates.append(cursor)
indices.append(hit)
} else {
cursor = target
dates.append(cursor)
indices.append(nil)
}
}
dates.reverse()
indices.reverse()
}
slotDates = dates
slotReadingIndices = indices
}

/// Index of the reading nearest `target` among those strictly before index
/// `limit`, if it lies within `tolerance` of the target.
private static func nearestReading(in sorted: [Date], before limit: Int, to target: Date, within tolerance: TimeInterval) -> Int? {
guard limit > 0 else { return nil }
// First index in 0 ..< limit whose date is >= target.
var low = 0
var high = limit
while low < high {
let mid = (low + high) / 2
if sorted[mid] < target { low = mid + 1 } else { high = mid }
}
var best: Int?
for candidate in [low - 1, low] where candidate >= 0 && candidate < limit {
let distance = abs(sorted[candidate].timeIntervalSince(target))
if distance <= tolerance, best.map({ distance < abs(sorted[$0].timeIntervalSince(target)) }) ?? true {
best = candidate
}
}
return best
}

/// The mark whose block contains `date`.
func slot(containing date: Date) -> Slot {
let cadence = Self.cadence
guard let firstSlot = slotDates.first, let lastSlot = slotDates.last else {
return virtualSlot(origin: Date(timeIntervalSince1970: 0), nearestTo: date)
}
if date < firstSlot.addingTimeInterval(-cadence / 2) {
return virtualSlot(origin: firstSlot, nearestTo: date)
}
if date >= lastSlot.addingTimeInterval(cadence / 2) {
return virtualSlot(origin: lastSlot, nearestTo: date)
}

// Index of the last mark at or before `date`; the midpoint to its
// successor decides between the two.
var low = 0
var high = slotDates.count - 1
while low < high {
let mid = (low + high + 1) / 2
if slotDates[mid] <= date { low = mid } else { high = mid - 1 }
}
var i = low
if date < firstSlot {
i = 0
} else if i + 1 < slotDates.count, date >= midpoint(slotDates[i], slotDates[i + 1]) {
i += 1
}
return gridSlot(at: i)
}

private func gridSlot(at i: Int) -> Slot {
let cadence = Self.cadence
let date = slotDates[i]
let blockStart = i > 0 ? midpoint(slotDates[i - 1], date) : date.addingTimeInterval(-cadence / 2)
let blockEnd = i + 1 < slotDates.count ? midpoint(date, slotDates[i + 1]) : date.addingTimeInterval(cadence / 2)
return Slot(date: date, readingIndex: slotReadingIndices[i], blockStart: blockStart, blockEnd: blockEnd)
}

/// Virtual mark on the grid anchored at `origin`; a halfway date belongs
/// to the later mark, matching the half-open blocks.
private func virtualSlot(origin: Date, nearestTo date: Date) -> Slot {
let cadence = Self.cadence
let k = (date.timeIntervalSince(origin) / cadence + 0.5).rounded(.down)
return Slot(
date: origin.addingTimeInterval(k * cadence),
readingIndex: nil,
blockStart: origin.addingTimeInterval((k - 0.5) * cadence),
blockEnd: origin.addingTimeInterval((k + 0.5) * cadence)
)
}

private func midpoint(_ a: Date, _ b: Date) -> Date {
a.addingTimeInterval(b.timeIntervalSince(a) / 2)
}
}
9 changes: 9 additions & 0 deletions LoopFollow/Charts/BGChartStubs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ extension MainViewController {

func updateBGGraphSettings() {
chartModel.rebuild()

// Re-route the stored predBGs in case the prediction style
// (cone/lines) changed; rebuild() alone only redraws what was
// already routed.
updateOpenAPSPredictionDisplay()

// 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())
}

private func recomputeTopBG() {
Expand Down
Loading
Loading