diff --git a/LoopFollow/Controllers/Nightscout/CAge.swift b/LoopFollow/Controllers/Nightscout/CAge.swift index 7e20bfade..ae02c46b4 100644 --- a/LoopFollow/Controllers/Nightscout/CAge.swift +++ b/LoopFollow/Controllers/Nightscout/CAge.swift @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index bd5699726..423c807ff 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -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() @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 7dfdb4cdb..f15bef344 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 71909cc49..d1f28dac7 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/SAge.swift b/LoopFollow/Controllers/Nightscout/SAge.swift index 580d39f1a..596974e12 100644 --- a/LoopFollow/Controllers/Nightscout/SAge.swift +++ b/LoopFollow/Controllers/Nightscout/SAge.swift @@ -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() @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift index 58314a4d9..9fe4ba7b1 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 23e2e0427..e9335efc4 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift index cc4435212..9615f53ce 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift @@ -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 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift index f896e4941..012bed356 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift @@ -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 diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 07a6246bc..1fa6c5bdb 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -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 diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index 18353dce0..71f84b038 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -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 @@ -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 @@ -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 diff --git a/Tests/NightscoutTreatmentDateTests.swift b/Tests/NightscoutTreatmentDateTests.swift new file mode 100644 index 000000000..abfb74105 --- /dev/null +++ b/Tests/NightscoutTreatmentDateTests.swift @@ -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) + } +}