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
6 changes: 5 additions & 1 deletion LoopFollow/Controllers/Nightscout/CAge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ extension MainViewController {
.withTime,
.withDashSeparatorInDate,
.withColonSeparatorInTime]
Storage.shared.cageInsertTime.value = formatter.date(from: lastCageString)?.timeIntervalSince1970 as! TimeInterval
guard let cageInsertTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 else {
LogManager.shared.log(category: .nightscout, message: "updateCage: unparseable created_at '\(lastCageString)'")
return
}
Storage.shared.cageInsertTime.value = cageInsertTime
if let cageTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 {
let now = dateTimeUtils.getNowTimeIntervalUTC()
let secondsAgo = now - cageTime
Expand Down
12 changes: 8 additions & 4 deletions LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ extension MainViewController {
latestCOB = cobMetric
}

if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] {
let prediction = predictdata["values"] as! [Double]
Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!))))
if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject],
let prediction = predictdata["values"] as? [Double],
let lastPrediction = prediction.last
{
Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(lastPrediction))))
Observable.shared.predictionColor.value = .purple
if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty {
predictionData.removeAll()
Expand Down Expand Up @@ -108,7 +110,9 @@ extension MainViewController {
Observable.shared.deviceRecBolus.value = nil
}
if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {
if let tempBasalString = loopStatus["timestamp"] as? String,
let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970
{
var lastBGTime = lastLoopTime
if bgData.count > 0 {
lastBGTime = bgData[bgData.count - 1].date
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ extension MainViewController {
}

if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {
if let tempBasalString = loopStatus["timestamp"] as? String,
let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970
{
var lastBGTime = updatedTime ?? Date().timeIntervalSince1970
if bgData.count > 0 {
lastBGTime = bgData[bgData.count - 1].date
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/IAge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ extension MainViewController {
.withDashSeparatorInDate,
.withColonSeparatorInTime]

if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 {
if let iageTime = formatter.date(from: lastIageString)?.timeIntervalSince1970 {
Storage.shared.iageInsertTime.value = iageTime
let now = dateTimeUtils.getNowTimeIntervalUTC()
let secondsAgo = now - iageTime
Expand Down
10 changes: 7 additions & 3 deletions LoopFollow/Controllers/Nightscout/SAge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,18 @@ extension MainViewController {
return
}
currentSage = data[0]
var lastSageString = data[0].created_at
let lastSageString = data[0].created_at

let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate,
.withTime,
.withDashSeparatorInDate,
.withColonSeparatorInTime]
Storage.shared.sageInsertTime.value = formatter.date(from: lastSageString)?.timeIntervalSince1970 as! TimeInterval
guard let sageInsertTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 else {
LogManager.shared.log(category: .nightscout, message: "updateSage: unparseable created_at '\(lastSageString)'")
return
}
Storage.shared.sageInsertTime.value = sageInsertTime

// -- Auto-snooze CGM start ────────────────────────────────────────────────
let now = Date()
Expand All @@ -62,7 +66,7 @@ extension MainViewController {
}
}

if let sageTime = formatter.date(from: (lastSageString as! String))?.timeIntervalSince1970 {
if let sageTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 {
let now = dateTimeUtils.getNowTimeIntervalUTC()
let secondsAgo = now - sageTime

Expand Down
11 changes: 1 addition & 10 deletions LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,7 @@ extension MainViewController {
var lastFoundIndex = 0

for currentEntry in entries.reversed() {
var bolusDate: String
if currentEntry["timestamp"] != nil {
bolusDate = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
bolusDate = currentEntry["created_at"] as! String
} else {
continue
}

guard let parsedDate = NightscoutUtils.parseDate(bolusDate),
guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry),
let bolus = currentEntry["insulin"] as? Double else { continue }

let dateTimeStamp = parsedDate.timeIntervalSince1970
Expand Down
11 changes: 1 addition & 10 deletions LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,9 @@ extension MainViewController {
var lastFoundSmb = 0

for currentEntry in entries.reversed() {
var carbDate: String
if currentEntry["timestamp"] != nil {
carbDate = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
carbDate = currentEntry["created_at"] as! String
} else {
continue
}

let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0

guard let parsedDate = NightscoutUtils.parseDate(carbDate),
guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry),
let carbs = currentEntry["carbs"] as? Double else { continue }

let dateTimeStamp = parsedDate.timeIntervalSince1970
Expand Down
11 changes: 1 addition & 10 deletions LoopFollow/Controllers/Nightscout/Treatments/Notes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,7 @@ extension MainViewController {
for currentEntry in entries.reversed() {
guard let currentEntry = currentEntry as? [String: AnyObject] else { continue }

var date: String
if currentEntry["timestamp"] != nil {
date = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
date = currentEntry["created_at"] as! String
} else {
continue
}

if let parsedDate = NightscoutUtils.parseDate(date) {
if let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry) {
let dateTimeStamp = parsedDate.timeIntervalSince1970
let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex)
lastFoundIndex = sgv.foundIndex
Expand Down
11 changes: 1 addition & 10 deletions LoopFollow/Controllers/Nightscout/Treatments/SMB.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,7 @@ extension MainViewController {
var lastFoundIndex = 0

for currentEntry in entries.reversed() {
var bolusDate: String
if currentEntry["timestamp"] != nil {
bolusDate = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
bolusDate = currentEntry["created_at"] as! String
} else {
continue
}

guard let parsedDate = NightscoutUtils.parseDate(bolusDate),
guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry),
let bolus = currentEntry["insulin"] as? Double else { continue }

let dateTimeStamp = parsedDate.timeIntervalSince1970
Expand Down
26 changes: 26 additions & 0 deletions LoopFollow/Helpers/NightscoutUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,32 @@ class NightscoutUtils {
task.resume()
}

/// Effective time of a Nightscout treatment: `timestamp` when present, otherwise `created_at`.
/// Uploaders disagree on the value's type (ISO 8601 string, epoch milliseconds, or JSON `null`),
/// so never force-cast it; an unreadable value yields `nil` and the entry is skipped.
static func treatmentDate(from entry: [String: AnyObject]) -> Date? {
for key in ["timestamp", "created_at"] {
guard let value = entry[key], !(value is NSNull) else { continue }
return parseDateValue(value)
}
return nil
}

static func parseDateValue(_ value: Any) -> Date? {
switch value {
case let string as String:
return parseDate(string)
case let number as NSNumber:
// Booleans are NSNumbers too; they are never a date.
if CFGetTypeID(number) == CFBooleanGetTypeID() { return nil }
let raw = number.doubleValue
// Epoch milliseconds are ~1e12; epoch seconds are ~1e9.
return Date(timeIntervalSince1970: raw > 1e11 ? raw / 1000 : raw)
default:
return nil
}
}

static func parseDate(_ rawString: String) -> Date? {
var mutableDate = rawString

Expand Down
33 changes: 3 additions & 30 deletions LoopFollow/Stats/StatsDataFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +201,7 @@ class StatsDataFetcher {
var lastFoundIndex = 0

for currentEntry in bolusEntries.reversed() {
var bolusDate: String
if currentEntry["timestamp"] != nil {
bolusDate = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
bolusDate = currentEntry["created_at"] as! String
} else {
continue
}

guard let parsedDate = NightscoutUtils.parseDate(bolusDate),
guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry),
let bolus = currentEntry["insulin"] as? Double else { continue }

let dateTimeStamp = parsedDate.timeIntervalSince1970
Expand Down Expand Up @@ -250,16 +241,7 @@ class StatsDataFetcher {
var lastFoundIndex = 0

for currentEntry in smbEntries.reversed() {
var bolusDate: String
if currentEntry["timestamp"] != nil {
bolusDate = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
bolusDate = currentEntry["created_at"] as! String
} else {
continue
}

guard let parsedDate = NightscoutUtils.parseDate(bolusDate),
guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry),
let bolus = currentEntry["insulin"] as? Double else { continue }

let dateTimeStamp = parsedDate.timeIntervalSince1970
Expand Down Expand Up @@ -296,18 +278,9 @@ class StatsDataFetcher {
var lastFoundBolus = 0

for currentEntry in carbEntries.reversed() {
var carbDate: String
if currentEntry["timestamp"] != nil {
carbDate = currentEntry["timestamp"] as! String
} else if currentEntry["created_at"] != nil {
carbDate = currentEntry["created_at"] as! String
} else {
continue
}

let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0

guard let parsedDate = NightscoutUtils.parseDate(carbDate),
guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry),
let carbs = currentEntry["carbs"] as? Double else { continue }

let dateTimeStamp = parsedDate.timeIntervalSince1970
Expand Down
53 changes: 53 additions & 0 deletions Tests/NightscoutTreatmentDateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// LoopFollow
// NightscoutTreatmentDateTests.swift

import Foundation
@testable import LoopFollow
import Testing

struct NightscoutTreatmentDateTests {
private typealias Entry = [String: AnyObject]

private let expected = Date(timeIntervalSince1970: 1_787_000_000)

@Test("parses an ISO 8601 timestamp string")
func parsesStringTimestamp() {
let entry: Entry = ["timestamp": "2026-08-17T20:53:20.000Z" as AnyObject]
#expect(NightscoutUtils.treatmentDate(from: entry) == expected)
}

@Test("falls back to created_at when timestamp is missing")
func fallsBackToCreatedAt() {
let entry: Entry = ["created_at": "2026-08-17T20:53:20Z" as AnyObject]
#expect(NightscoutUtils.treatmentDate(from: entry) == expected)
}

@Test("falls back to created_at when timestamp is JSON null")
func nullTimestampFallsBack() {
let entry: Entry = [
"timestamp": NSNull(),
"created_at": "2026-08-17T20:53:20Z" as AnyObject,
]
#expect(NightscoutUtils.treatmentDate(from: entry) == expected)
}

@Test("accepts epoch milliseconds and seconds")
func parsesNumericTimestamp() {
let millis: Entry = ["timestamp": NSNumber(value: 1_787_000_000_000)]
let seconds: Entry = ["timestamp": NSNumber(value: 1_787_000_000)]
#expect(NightscoutUtils.treatmentDate(from: millis) == expected)
#expect(NightscoutUtils.treatmentDate(from: seconds) == expected)
}

@Test("returns nil instead of trapping on unsupported values")
func unsupportedValuesReturnNil() {
let bool: Entry = ["timestamp": NSNumber(value: true)]
let array: Entry = ["timestamp": ["2026-08-17T20:53:20Z"] as AnyObject]
let garbage: Entry = ["timestamp": "not a date" as AnyObject]
let empty: Entry = [:]
#expect(NightscoutUtils.treatmentDate(from: bool) == nil)
#expect(NightscoutUtils.treatmentDate(from: array) == nil)
#expect(NightscoutUtils.treatmentDate(from: garbage) == nil)
#expect(NightscoutUtils.treatmentDate(from: empty) == nil)
}
}