| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577 |
- // LoopFollow
- // StatsDataFetcher.swift
- import Foundation
- class StatsDataFetcher {
- /// See StatsDataService.mainViewController — the injected reference can be
- /// nil at cold launch, so fall back to the shared engine.
- private weak var injectedMainViewController: MainViewController?
- var mainViewController: MainViewController? {
- injectedMainViewController ?? MainViewController.shared
- }
- weak var dataService: StatsDataService?
- init(mainViewController: MainViewController?) {
- injectedMainViewController = mainViewController
- }
- func fetchBGData(days: Int, completion: @escaping () -> Void) {
- guard let mainVC = mainViewController, IsNightscoutEnabled() else {
- completion()
- return
- }
- var parameters: [String: String] = [:]
- let utcISODateFormatter = ISO8601DateFormatter()
- let startDate = dataService?.startDate ?? dateTimeUtils.displayCalendar().date(byAdding: .day, value: -1 * days, to: Date())!
- parameters["count"] = "\(days * globalVariables.maxExpectedUploaders * 24 * 60 / 5)"
- parameters["find[dateString][$gte]"] = utcISODateFormatter.string(from: startDate)
- parameters["find[type][$ne]"] = "cal"
- NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in
- switch result {
- case let .success(entriesResponse):
- var nsData = entriesResponse
- DispatchQueue.main.async {
- // Transform NS data
- for i in 0 ..< nsData.count {
- nsData[i].date /= 1000
- nsData[i].date.round(FloatingPointRoundingRule.toNearestOrEven)
- }
- var nsData2: [ShareGlucoseData] = []
- var lastAddedTime = Double.infinity
- var lastAddedSGV: Int?
- let minInterval: Double = 30
- for reading in nsData {
- if (lastAddedSGV == nil || lastAddedSGV != reading.sgv) || (lastAddedTime - reading.date >= minInterval) {
- nsData2.append(reading)
- lastAddedTime = reading.date
- lastAddedSGV = reading.sgv
- }
- }
- let cutoffTime = self.dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
- mainVC.statsBGData.removeAll { $0.date < cutoffTime }
- let existingDates = Set(mainVC.statsBGData.map { Int($0.date) })
- for reading in nsData2 {
- if !existingDates.contains(Int(reading.date)), reading.date >= cutoffTime {
- mainVC.statsBGData.append(reading)
- }
- }
- mainVC.statsBGData.sort { $0.date < $1.date }
- completion()
- }
- case let .failure(error):
- LogManager.shared.log(category: .nightscout, message: "Failed to fetch stats BG data: \(error)", limitIdentifier: "Failed to fetch stats BG data")
- DispatchQueue.main.async {
- completion()
- }
- }
- }
- }
- func fetchTreatmentsData(days: Int, completion: @escaping () -> Void) {
- guard let mainVC = mainViewController, IsNightscoutEnabled(), Storage.shared.downloadTreatments.value else {
- completion()
- return
- }
- ensureBasalProfileLoaded(mainVC: mainVC) {
- self.fetchAndMergeTreatments(days: days, mainVC: mainVC, completion: completion)
- }
- }
- private func ensureBasalProfileLoaded(mainVC: MainViewController, completion: @escaping () -> Void) {
- if !mainVC.basalProfile.isEmpty {
- completion()
- return
- }
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let parameters: [String: String] = [
- "count": "1",
- "find[startDate][$lte]": formatter.string(from: Date().addingTimeInterval(60)),
- ]
- NightscoutUtils.executeRequest(eventType: .profile, parameters: parameters) { (result: Result<[NSProfile], Error>) in
- switch result {
- case let .success(profiles):
- guard let profileData = profiles.first else {
- LogManager.shared.log(category: .nightscout, message: "ensureBasalProfileLoaded, no profile records returned")
- DispatchQueue.main.async { completion() }
- return
- }
- let profileStore = profileData.store["default"] ??
- profileData.store["Default"] ??
- profileData.store[profileData.defaultProfile]
- DispatchQueue.main.async {
- if let profileStore {
- mainVC.basalProfile = profileStore.basal.map {
- MainViewController.basalProfileStruct(
- value: $0.value,
- time: $0.time,
- timeAsSeconds: $0.timeAsSeconds
- )
- }
- }
- completion()
- }
- case let .failure(error):
- LogManager.shared.log(
- category: .nightscout,
- message: "Failed to fetch profile data for stats basal calculations: \(error.localizedDescription)"
- )
- DispatchQueue.main.async {
- completion()
- }
- }
- }
- }
- private func fetchAndMergeTreatments(days: Int, mainVC: MainViewController, completion: @escaping () -> Void) {
- let utcISODateFormatter = ISO8601DateFormatter()
- utcISODateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- utcISODateFormatter.timeZone = TimeZone(abbreviation: "UTC")
- let startDate = dataService?.startDate ?? dateTimeUtils.displayCalendar().date(byAdding: .day, value: -1 * days, to: Date())!
- let endDate = dataService?.endDate ?? Date()
- let startTimeString = utcISODateFormatter.string(from: startDate)
- let currentTimeString = utcISODateFormatter.string(from: endDate)
- let estimatedCount = max(days * 100, 5000)
- let parameters: [String: String] = [
- "find[created_at][$gte]": startTimeString,
- "find[created_at][$lte]": currentTimeString,
- "count": "\(estimatedCount)",
- ]
- NightscoutUtils.executeDynamicRequest(eventType: .treatments, parameters: parameters) { (result: Result<Any, Error>) in
- switch result {
- case let .success(data):
- if let entries = data as? [[String: AnyObject]] {
- DispatchQueue.main.async {
- self.fetchAndMergeBolusData(entries: entries, days: days, mainVC: mainVC)
- self.fetchAndMergeSMBData(entries: entries, days: days, mainVC: mainVC)
- self.fetchAndMergeCarbData(entries: entries, days: days, mainVC: mainVC)
- self.fetchAndMergeBasalData(entries: entries, days: days, mainVC: mainVC)
- completion()
- }
- } else {
- DispatchQueue.main.async {
- completion()
- }
- }
- case let .failure(error):
- LogManager.shared.log(category: .nightscout, message: "Failed to fetch stats treatments data: \(error.localizedDescription)")
- DispatchQueue.main.async {
- completion()
- }
- }
- }
- }
- private func fetchAndMergeBolusData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
- let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
- var bolusEntries: [[String: AnyObject]] = []
- for entry in entries {
- guard let eventType = entry["eventType"] as? String else { continue }
- if eventType == "Correction Bolus" || eventType == "Bolus" || eventType == "External Insulin" {
- if let automatic = entry["automatic"] as? Bool, automatic {
- continue
- }
- bolusEntries.append(entry)
- } else if eventType == "Meal Bolus" {
- bolusEntries.append(entry)
- }
- }
- mainVC.statsBolusData.removeAll { $0.date < cutoffTime }
- let existingDates = Set(mainVC.statsBolusData.map { Int($0.date) })
- 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),
- let bolus = currentEntry["insulin"] as? Double else { continue }
- let dateTimeStamp = parsedDate.timeIntervalSince1970
- if dateTimeStamp < cutoffTime { continue }
- // Avoid duplicates (use Int to handle floating point precision)
- if existingDates.contains(Int(dateTimeStamp)) { continue }
- let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex)
- lastFoundIndex = sgv.foundIndex
- let dot = MainViewController.bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20))
- mainVC.statsBolusData.append(dot)
- }
- mainVC.statsBolusData.sort { $0.date < $1.date }
- }
- private func fetchAndMergeSMBData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
- let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
- var smbEntries: [[String: AnyObject]] = []
- for entry in entries {
- guard let eventType = entry["eventType"] as? String else { continue }
- if eventType == "SMB" {
- smbEntries.append(entry)
- } else if eventType == "Correction Bolus" || eventType == "Bolus" || eventType == "External Insulin" {
- if let automatic = entry["automatic"] as? Bool, automatic {
- smbEntries.append(entry)
- }
- }
- }
- mainVC.statsSMBData.removeAll { $0.date < cutoffTime }
- let existingDates = Set(mainVC.statsSMBData.map { Int($0.date) })
- 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),
- let bolus = currentEntry["insulin"] as? Double else { continue }
- let dateTimeStamp = parsedDate.timeIntervalSince1970
- if dateTimeStamp < cutoffTime { continue }
- if existingDates.contains(Int(dateTimeStamp)) { continue }
- let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex)
- lastFoundIndex = sgv.foundIndex
- let dot = MainViewController.bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20))
- mainVC.statsSMBData.append(dot)
- }
- mainVC.statsSMBData.sort { $0.date < $1.date }
- }
- private func fetchAndMergeCarbData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
- let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
- let now = dataService?.endDate.timeIntervalSince1970 ?? Date().timeIntervalSince1970
- var carbEntries: [[String: AnyObject]] = []
- for entry in entries {
- guard let eventType = entry["eventType"] as? String else { continue }
- if eventType == "Carb Correction" || eventType == "Meal Bolus" {
- carbEntries.append(entry)
- }
- }
- mainVC.statsCarbData.removeAll { $0.date < cutoffTime || $0.date > now }
- let existingDates = Set(mainVC.statsCarbData.map { Int($0.date) })
- var lastFoundIndex = 0
- 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),
- let carbs = currentEntry["carbs"] as? Double else { continue }
- let dateTimeStamp = parsedDate.timeIntervalSince1970
- if dateTimeStamp < cutoffTime || dateTimeStamp > now { continue }
- if existingDates.contains(Int(dateTimeStamp)) { continue }
- let sgv = mainVC.findNearestBGbyTime(needle: dateTimeStamp, haystack: mainVC.statsBGData, startingIndex: lastFoundIndex)
- lastFoundIndex = sgv.foundIndex
- var offset = -50
- if sgv.sgv < Double(mainVC.calculateMaxBgGraphValue() - 100) {
- let bolusTime = mainVC.findNearestBolusbyTime(timeWithin: 300, needle: dateTimeStamp, haystack: mainVC.statsBolusData, startingIndex: lastFoundBolus)
- lastFoundBolus = bolusTime.foundIndex
- offset = bolusTime.offset ? 70 : 20
- }
- let dot = MainViewController.carbGraphStruct(value: Double(carbs), date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime)
- mainVC.statsCarbData.append(dot)
- }
- mainVC.statsCarbData.sort { $0.date < $1.date }
- }
- private func fetchAndMergeBasalData(entries: [[String: AnyObject]], days: Int, mainVC: MainViewController) {
- let cutoffTime = dataService?.startDate.timeIntervalSince1970 ?? (Date().timeIntervalSince1970 - (Double(days) * 24 * 60 * 60))
- let now = dataService?.endDate.timeIntervalSince1970 ?? Date().timeIntervalSince1970
- var basalEntries: [[String: AnyObject]] = []
- for entry in entries {
- guard let eventType = entry["eventType"] as? String else { continue }
- if eventType == "Temp Basal" {
- basalEntries.append(entry)
- }
- }
- mainVC.statsBasalData.removeAll { $0.date < cutoffTime }
- // Clear and rebuild temp basal entries for stats calculation
- dataService?.tempBasalEntries.removeAll { $0.startTime < cutoffTime }
- var existingTempBasalTimes = Set(dataService?.tempBasalEntries.map { Int($0.startTime) } ?? [])
- var existingDates = Set(mainVC.statsBasalData.map { Int($0.date) })
- var tempArray = basalEntries
- tempArray.reverse()
- for i in 0 ..< tempArray.count {
- guard let currentEntry = tempArray[i] as [String: AnyObject]? else { continue }
- let dateString = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String
- guard let rawDateStr = dateString,
- let dateParsed = NightscoutUtils.parseDate(rawDateStr)
- else {
- continue
- }
- let dateTimeStamp = dateParsed.timeIntervalSince1970
- if dateTimeStamp < cutoffTime { continue }
- guard let basalRate = currentEntry["absolute"] as? Double else {
- continue
- }
- var duration = currentEntry["duration"] as? Double ?? 0.0
- // Store raw temp basal entry for stats calculation
- // Adjust duration if it overlaps with next temp basal
- var effectiveDuration = duration
- if i < tempArray.count - 1 {
- let nextEntry = tempArray[i + 1] as [String: AnyObject]?
- let nextDateStr = nextEntry?["timestamp"] as? String ?? nextEntry?["created_at"] as? String
- if let rawNext = nextDateStr,
- let nextDateParsed = NightscoutUtils.parseDate(rawNext)
- {
- let nextDateTimeStamp = nextDateParsed.timeIntervalSince1970
- let tempBasalEnd = dateTimeStamp + (duration * 60)
- if nextDateTimeStamp < tempBasalEnd {
- // Adjust duration to end when next temp basal starts
- effectiveDuration = (nextDateTimeStamp - dateTimeStamp) / 60.0
- }
- }
- }
- if !existingTempBasalTimes.contains(Int(dateTimeStamp)) {
- let tempBasalEntry = StatsDataService.TempBasalEntry(
- rate: basalRate,
- startTime: dateTimeStamp,
- durationMinutes: effectiveDuration > 0 ? effectiveDuration : 30.0
- )
- dataService?.tempBasalEntries.append(tempBasalEntry)
- existingTempBasalTimes.insert(Int(dateTimeStamp))
- }
- if i > 0 {
- let priorEntry = tempArray[i - 1] as [String: AnyObject]?
- let priorDateStr = priorEntry?["timestamp"] as? String ?? priorEntry?["created_at"] as? String
- if let rawPrior = priorDateStr,
- let priorDateParsed = NightscoutUtils.parseDate(rawPrior)
- {
- let priorDateTimeStamp = priorDateParsed.timeIntervalSince1970
- let priorDuration = priorEntry?["duration"] as? Double ?? 0.0
- let priorEndTime = priorDateTimeStamp + (priorDuration * 60)
- if (dateTimeStamp - priorEndTime) > 15 {
- let gapEntries = createScheduledBasalEntriesForGap(
- from: priorEndTime,
- to: dateTimeStamp,
- profile: mainVC.basalProfile,
- existingDates: existingDates
- )
- for entry in gapEntries {
- if !existingDates.contains(Int(entry.date)) {
- mainVC.statsBasalData.append(entry)
- existingDates.insert(Int(entry.date))
- }
- }
- }
- }
- }
- let startDot = MainViewController.basalGraphStruct(basalRate: basalRate, date: dateTimeStamp)
- if !existingDates.contains(Int(startDot.date)) {
- mainVC.statsBasalData.append(startDot)
- existingDates.insert(Int(startDot.date))
- }
- var endTime = dateTimeStamp + (duration * 60)
- if i == tempArray.count - 1, duration == 0.0 {
- endTime = dateTimeStamp + (30 * 60)
- }
- if i < tempArray.count - 1 {
- let nextEntry = tempArray[i + 1] as [String: AnyObject]?
- let nextDateStr = nextEntry?["timestamp"] as? String ?? nextEntry?["created_at"] as? String
- if let rawNext = nextDateStr,
- let nextDateParsed = NightscoutUtils.parseDate(rawNext)
- {
- let nextDateTimeStamp = nextDateParsed.timeIntervalSince1970
- if nextDateTimeStamp < endTime {
- endTime = nextDateTimeStamp
- }
- }
- }
- let endDot = MainViewController.basalGraphStruct(basalRate: basalRate, date: endTime)
- if !existingDates.contains(Int(endDot.date)) {
- mainVC.statsBasalData.append(endDot)
- existingDates.insert(Int(endDot.date))
- }
- }
- if !tempArray.isEmpty {
- let firstEntry = tempArray.first as [String: AnyObject]?
- let firstDateStr = firstEntry?["timestamp"] as? String ?? firstEntry?["created_at"] as? String
- if let rawFirst = firstDateStr,
- let firstDateParsed = NightscoutUtils.parseDate(rawFirst)
- {
- let firstTempBasalStart = firstDateParsed.timeIntervalSince1970
- if firstTempBasalStart > cutoffTime {
- let gapEntries = createScheduledBasalEntriesForGap(
- from: cutoffTime,
- to: firstTempBasalStart,
- profile: mainVC.basalProfile,
- existingDates: existingDates
- )
- for entry in gapEntries {
- if !existingDates.contains(Int(entry.date)) {
- mainVC.statsBasalData.append(entry)
- existingDates.insert(Int(entry.date))
- }
- }
- }
- }
- } else if !mainVC.basalProfile.isEmpty {
- let gapEntries = createScheduledBasalEntriesForGap(
- from: cutoffTime,
- to: now,
- profile: mainVC.basalProfile,
- existingDates: existingDates
- )
- for entry in gapEntries {
- if !existingDates.contains(Int(entry.date)) {
- mainVC.statsBasalData.append(entry)
- existingDates.insert(Int(entry.date))
- }
- }
- }
- if !tempArray.isEmpty {
- let lastEntry = tempArray.last as [String: AnyObject]?
- let lastDateStr = lastEntry?["timestamp"] as? String ?? lastEntry?["created_at"] as? String
- let lastDuration = lastEntry?["duration"] as? Double ?? 30.0
- if let rawLast = lastDateStr,
- let lastDateParsed = NightscoutUtils.parseDate(rawLast)
- {
- let lastTempBasalEnd = lastDateParsed.timeIntervalSince1970 + (lastDuration * 60)
- if lastTempBasalEnd < now {
- let gapEntries = createScheduledBasalEntriesForGap(
- from: lastTempBasalEnd,
- to: now,
- profile: mainVC.basalProfile,
- existingDates: existingDates
- )
- for entry in gapEntries {
- if !existingDates.contains(Int(entry.date)) {
- mainVC.statsBasalData.append(entry)
- existingDates.insert(Int(entry.date))
- }
- }
- }
- }
- }
- mainVC.statsBasalData.sort { $0.date < $1.date }
- }
- private func createScheduledBasalEntriesForGap(
- from startTime: TimeInterval,
- to endTime: TimeInterval,
- profile: [MainViewController.basalProfileStruct],
- existingDates: Set<Int>
- ) -> [MainViewController.basalGraphStruct] {
- guard !profile.isEmpty, endTime > startTime else { return [] }
- var entries: [MainViewController.basalGraphStruct] = []
- let sortedProfile = profile.sorted { $0.timeAsSeconds < $1.timeAsSeconds }
- let calendar = dateTimeUtils.displayCalendar()
- var currentTime = startTime
- while currentTime < endTime {
- let currentDate = Date(timeIntervalSince1970: currentTime)
- let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970
- let nextDayStart = dayStart + 24 * 60 * 60
- for i in 0 ..< sortedProfile.count {
- let segmentRate = sortedProfile[i].value
- let segmentStartInDay = dayStart + sortedProfile[i].timeAsSeconds
- let segmentEndInDay: TimeInterval
- if i < sortedProfile.count - 1 {
- segmentEndInDay = dayStart + sortedProfile[i + 1].timeAsSeconds
- } else {
- segmentEndInDay = nextDayStart
- }
- let overlapStart = max(currentTime, segmentStartInDay)
- let overlapEnd = min(endTime, segmentEndInDay)
- if overlapEnd > overlapStart, !existingDates.contains(Int(overlapStart)) {
- let startEntry = MainViewController.basalGraphStruct(basalRate: segmentRate, date: overlapStart)
- entries.append(startEntry)
- if overlapEnd < endTime, !existingDates.contains(Int(overlapEnd)) {
- let endEntry = MainViewController.basalGraphStruct(basalRate: segmentRate, date: overlapEnd)
- entries.append(endEntry)
- }
- }
- }
- currentTime = nextDayStart
- }
- return entries
- }
- }
|