CarbsStorage.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import SwiftDate
  5. import Swinject
  6. protocol CarbsObserver {
  7. func carbsDidUpdate(_ carbs: [CarbsEntry])
  8. }
  9. protocol CarbsStorage {
  10. var updatePublisher: AnyPublisher<Void, Never> { get }
  11. func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async
  12. func deleteCarbs(_ treatmentObjectID: NSManagedObjectID) async
  13. func syncDate() -> Date
  14. func recent() -> [CarbsEntry]
  15. func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  16. func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  17. func deleteCarbs(at uniqueID: String, fpuID: String, complex: Bool)
  18. func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry]
  19. func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry]
  20. }
  21. final class BaseCarbsStorage: CarbsStorage, Injectable {
  22. private let processQueue = DispatchQueue(label: "BaseCarbsStorage.processQueue")
  23. @Injected() private var storage: FileStorage!
  24. @Injected() private var broadcaster: Broadcaster!
  25. @Injected() private var settings: SettingsManager!
  26. let coredataContext = CoreDataStack.shared.newTaskContext()
  27. private let updateSubject = PassthroughSubject<Void, Never>()
  28. var updatePublisher: AnyPublisher<Void, Never> {
  29. updateSubject.eraseToAnyPublisher()
  30. }
  31. init(resolver: Resolver) {
  32. injectServices(resolver)
  33. }
  34. func storeCarbs(_ entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  35. var entriesToStore = entries
  36. if areFetchedFromRemote {
  37. entriesToStore = await filterRemoteEntries(entries: entriesToStore)
  38. }
  39. await saveCarbsToCoreData(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
  40. await saveCarbEquivalents(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
  41. }
  42. private func filterRemoteEntries(entries: [CarbsEntry]) async -> [CarbsEntry] {
  43. // Fetch only the date property from Core Data
  44. guard let existing24hCarbEntries = await CoreDataStack.shared.fetchEntitiesAsync(
  45. ofType: CarbEntryStored.self,
  46. onContext: coredataContext,
  47. predicate: NSPredicate.predicateForOneDayAgo,
  48. key: "date",
  49. ascending: false,
  50. batchSize: 50,
  51. propertiesToFetch: ["date", "objectID"]
  52. ) as? [[String: Any]] else {
  53. return entries
  54. }
  55. // Extract dates into a set for efficient lookup
  56. // Since we are not dealing with NSManagedObjects directly it is safe to pass properties between threads
  57. let existingTimestamps = Set(existing24hCarbEntries.compactMap { $0["date"] as? Date })
  58. // Remove all entries that have a matching date in existingTimestamps
  59. var filteredEntries = entries
  60. filteredEntries.removeAll { entry in
  61. let entryDate = entry.actualDate ?? entry.createdAt
  62. return existingTimestamps.contains(entryDate)
  63. }
  64. return filteredEntries
  65. }
  66. /**
  67. Calculates the duration for processing FPUs (fat and protein units) based on the FPUs and the time cap.
  68. - The function uses predefined rules to determine the duration based on the number of FPUs.
  69. - Ensures that the duration does not exceed the time cap.
  70. - Parameters:
  71. - fpus: The number of FPUs calculated from fat and protein.
  72. - timeCap: The maximum allowed duration.
  73. - Returns: The computed duration in hours.
  74. */
  75. private func calculateComputedDuration(fpus: Decimal, timeCap: Int) -> Int {
  76. switch fpus {
  77. case ..<2:
  78. return 3
  79. case 2 ..< 3:
  80. return 4
  81. case 3 ..< 4:
  82. return 5
  83. default:
  84. return timeCap
  85. }
  86. }
  87. /**
  88. Processes fat and protein entries to generate future carb equivalents, ensuring each equivalent is at least 1.0 grams.
  89. - The function calculates the equivalent carb dosage size and adjusts the interval to ensure each equivalent is at least 1.0 grams.
  90. - Creates future carb entries based on the adjusted carb equivalent size and interval.
  91. - Parameters:
  92. - entries: An array of `CarbsEntry` objects representing the carbohydrate entries to be processed.
  93. - fat: The amount of fat in the last entry.
  94. - protein: The amount of protein in the last entry.
  95. - createdAt: The creation date of the last entry.
  96. - Returns: A tuple containing the array of future carb entries and the total carb equivalents.
  97. */
  98. private func processFPU(
  99. entries: [CarbsEntry],
  100. fat: Decimal,
  101. protein: Decimal,
  102. createdAt: Date,
  103. actualDate: Date?
  104. ) -> ([CarbsEntry], Decimal) {
  105. let interval = settings.settings.minuteInterval
  106. let timeCap = settings.settings.timeCap
  107. let adjustment = settings.settings.individualAdjustmentFactor
  108. let delay = settings.settings.delay
  109. let kcal = protein * 4 + fat * 9
  110. let carbEquivalents = (kcal / 10) * adjustment
  111. let fpus = carbEquivalents / 10
  112. var computedDuration = calculateComputedDuration(fpus: fpus, timeCap: timeCap)
  113. var carbEquivalentSize: Decimal = carbEquivalents / Decimal(computedDuration)
  114. carbEquivalentSize /= Decimal(60 / interval)
  115. if carbEquivalentSize < 1.0 {
  116. carbEquivalentSize = 1.0
  117. computedDuration = Int(carbEquivalents / carbEquivalentSize)
  118. }
  119. let roundedEquivalent: Double = round(Double(carbEquivalentSize * 10)) / 10
  120. carbEquivalentSize = Decimal(roundedEquivalent)
  121. var numberOfEquivalents = carbEquivalents / carbEquivalentSize
  122. var useDate = actualDate ?? createdAt
  123. let fpuID = entries.first?.fpuID ?? UUID().uuidString
  124. var futureCarbArray = [CarbsEntry]()
  125. var firstIndex = true
  126. while carbEquivalents > 0, numberOfEquivalents > 0 {
  127. useDate = firstIndex ? useDate.addingTimeInterval(delay.minutes.timeInterval) : useDate
  128. .addingTimeInterval(interval.minutes.timeInterval)
  129. firstIndex = false
  130. let eachCarbEntry = CarbsEntry(
  131. id: UUID().uuidString,
  132. createdAt: createdAt,
  133. actualDate: useDate,
  134. carbs: carbEquivalentSize,
  135. fat: 0,
  136. protein: 0,
  137. note: nil,
  138. enteredBy: CarbsEntry.local,
  139. isFPU: true,
  140. fpuID: fpuID
  141. )
  142. futureCarbArray.append(eachCarbEntry)
  143. numberOfEquivalents -= 1
  144. }
  145. return (futureCarbArray, carbEquivalents)
  146. }
  147. private func saveCarbEquivalents(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  148. guard let lastEntry = entries.last else { return }
  149. if let fat = lastEntry.fat, let protein = lastEntry.protein, fat > 0 || protein > 0 {
  150. let (futureCarbEquivalents, carbEquivalentCount) = processFPU(
  151. entries: entries,
  152. fat: fat,
  153. protein: protein,
  154. createdAt: lastEntry.createdAt,
  155. actualDate: lastEntry.actualDate
  156. )
  157. if carbEquivalentCount > 0 {
  158. await saveFPUToCoreDataAsBatchInsert(entries: futureCarbEquivalents, areFetchedFromRemote: areFetchedFromRemote)
  159. }
  160. }
  161. }
  162. private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  163. guard let entry = entries.last, entry.carbs != 0 else { return }
  164. await coredataContext.perform {
  165. let newItem = CarbEntryStored(context: self.coredataContext)
  166. newItem.date = entry.actualDate ?? entry.createdAt
  167. newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
  168. newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
  169. newItem.protein = Double(truncating: NSDecimalNumber(decimal: entry.protein ?? 0))
  170. newItem.note = entry.note
  171. newItem.id = UUID()
  172. newItem.isFPU = false
  173. newItem.isUploadedToNS = areFetchedFromRemote ? true : false
  174. newItem.isUploadedToHealth = false
  175. newItem.isUploadedToTidepool = false
  176. if entry.fat != nil, entry.protein != nil, let fpuId = entry.fpuID {
  177. newItem.fpuID = UUID(uuidString: fpuId)
  178. }
  179. do {
  180. guard self.coredataContext.hasChanges else { return }
  181. try self.coredataContext.save()
  182. } catch {
  183. print(error.localizedDescription)
  184. }
  185. }
  186. }
  187. private func saveFPUToCoreDataAsBatchInsert(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  188. let commonFPUID = UUID(
  189. uuidString: entries.first?.fpuID ?? UUID()
  190. .uuidString
  191. ) // all fpus should only get ONE id per batch insert to be able to delete them referencing the fpuID
  192. var entrySlice = ArraySlice(entries) // convert to ArraySlice
  193. let batchInsert = NSBatchInsertRequest(entity: CarbEntryStored.entity()) { (managedObject: NSManagedObject) -> Bool in
  194. guard let carbEntry = managedObject as? CarbEntryStored, let entry = entrySlice.popFirst(),
  195. let entryId = entry.id
  196. else {
  197. return true // return true to stop
  198. }
  199. carbEntry.date = entry.actualDate
  200. carbEntry.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
  201. carbEntry.id = UUID.init(uuidString: entryId)
  202. carbEntry.fpuID = commonFPUID
  203. carbEntry.isFPU = true
  204. carbEntry.isUploadedToNS = areFetchedFromRemote ? true : false
  205. // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
  206. return false // return false to continue
  207. }
  208. await coredataContext.perform {
  209. do {
  210. try self.coredataContext.execute(batchInsert)
  211. debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
  212. // Notify subscriber in Home State Model to update the FPU Array
  213. self.updateSubject.send(())
  214. } catch {
  215. debugPrint("Carbs Storage: \(DebuggingIdentifiers.failed) error while saving fpus to core data")
  216. }
  217. }
  218. }
  219. func syncDate() -> Date {
  220. Date().addingTimeInterval(-1.days.timeInterval)
  221. }
  222. func recent() -> [CarbsEntry] {
  223. storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self)?.reversed() ?? []
  224. }
  225. func deleteCarbs(_ treatmentObjectID: NSManagedObjectID) async {
  226. let taskContext = CoreDataStack.shared.newTaskContext()
  227. taskContext.name = "deleteContext"
  228. taskContext.transactionAuthor = "deleteCarbs"
  229. var carbEntry: CarbEntryStored?
  230. await taskContext.perform {
  231. do {
  232. carbEntry = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
  233. guard let carbEntry = carbEntry else {
  234. debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
  235. return
  236. }
  237. if carbEntry.isFPU, let fpuID = carbEntry.fpuID {
  238. // fetch request for all carb entries with the same id
  239. let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CarbEntryStored.fetchRequest()
  240. fetchRequest.predicate = NSPredicate(format: "fpuID == %@", fpuID as CVarArg)
  241. // NSBatchDeleteRequest
  242. let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
  243. deleteRequest.resultType = .resultTypeCount
  244. // execute the batch delete request
  245. let result = try taskContext.execute(deleteRequest) as? NSBatchDeleteResult
  246. debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)")
  247. // Notifiy subscribers of the batch delete
  248. self.updateSubject.send(())
  249. } else {
  250. taskContext.delete(carbEntry)
  251. guard taskContext.hasChanges else { return }
  252. try taskContext.save()
  253. debugPrint(
  254. "Data Table State: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
  255. )
  256. }
  257. } catch {
  258. debugPrint("\(DebuggingIdentifiers.failed) Error deleting carb entry: \(error.localizedDescription)")
  259. }
  260. }
  261. }
  262. func deleteCarbs(at uniqueID: String, fpuID: String, complex: Bool) {
  263. processQueue.sync {
  264. var allValues = storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self) ?? []
  265. if fpuID != "" {
  266. if allValues.firstIndex(where: { $0.fpuID == fpuID }) == nil {
  267. debug(.default, "Didn't find any carb equivalents to delete. ID to search for: " + fpuID.description)
  268. } else {
  269. allValues.removeAll(where: { $0.fpuID == fpuID })
  270. storage.save(allValues, as: OpenAPS.Monitor.carbHistory)
  271. broadcaster.notify(CarbsObserver.self, on: processQueue) {
  272. $0.carbsDidUpdate(allValues)
  273. }
  274. }
  275. }
  276. if fpuID == "" || complex {
  277. if allValues.firstIndex(where: { $0.id == uniqueID }) == nil {
  278. debug(.default, "Didn't find any carb entries to delete. ID to search for: " + uniqueID.description)
  279. } else {
  280. allValues.removeAll(where: { $0.id == uniqueID })
  281. storage.save(allValues, as: OpenAPS.Monitor.carbHistory)
  282. broadcaster.notify(CarbsObserver.self, on: processQueue) {
  283. $0.carbsDidUpdate(allValues)
  284. }
  285. }
  286. }
  287. }
  288. }
  289. func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  290. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  291. ofType: CarbEntryStored.self,
  292. onContext: coredataContext,
  293. predicate: NSPredicate.carbsNotYetUploadedToNightscout,
  294. key: "date",
  295. ascending: false
  296. )
  297. return await coredataContext.perform {
  298. guard let carbEntries = results as? [CarbEntryStored] else {
  299. return []
  300. }
  301. return carbEntries.map { result in
  302. NightscoutTreatment(
  303. duration: nil,
  304. rawDuration: nil,
  305. rawRate: nil,
  306. absolute: nil,
  307. rate: nil,
  308. eventType: .nsCarbCorrection,
  309. createdAt: result.date,
  310. enteredBy: CarbsEntry.local,
  311. bolus: nil,
  312. insulin: nil,
  313. notes: result.note,
  314. carbs: Decimal(result.carbs),
  315. fat: Decimal(result.fat),
  316. protein: Decimal(result.protein),
  317. foodType: result.note,
  318. targetTop: nil,
  319. targetBottom: nil,
  320. id: result.id?.uuidString
  321. )
  322. }
  323. }
  324. }
  325. func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  326. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  327. ofType: CarbEntryStored.self,
  328. onContext: coredataContext,
  329. predicate: NSPredicate.fpusNotYetUploadedToNightscout,
  330. key: "date",
  331. ascending: false
  332. )
  333. return await coredataContext.perform {
  334. guard let fpuEntries = results as? [CarbEntryStored] else { return [] }
  335. return fpuEntries.map { result in
  336. NightscoutTreatment(
  337. duration: nil,
  338. rawDuration: nil,
  339. rawRate: nil,
  340. absolute: nil,
  341. rate: nil,
  342. eventType: .nsCarbCorrection,
  343. createdAt: result.date,
  344. enteredBy: CarbsEntry.local,
  345. bolus: nil,
  346. insulin: nil,
  347. notes: result.note,
  348. carbs: Decimal(result.carbs),
  349. fat: Decimal(result.fat),
  350. protein: Decimal(result.protein),
  351. foodType: result.note,
  352. targetTop: nil,
  353. targetBottom: nil,
  354. id: result.fpuID?.uuidString
  355. )
  356. }
  357. }
  358. }
  359. func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry] {
  360. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  361. ofType: CarbEntryStored.self,
  362. onContext: coredataContext,
  363. predicate: NSPredicate.carbsNotYetUploadedToHealth,
  364. key: "date",
  365. ascending: false
  366. )
  367. guard let carbEntries = results as? [CarbEntryStored] else {
  368. return []
  369. }
  370. return await coredataContext.perform {
  371. return carbEntries.map { result in
  372. CarbsEntry(
  373. id: result.id?.uuidString,
  374. createdAt: result.date ?? Date(),
  375. actualDate: result.date,
  376. carbs: Decimal(result.carbs),
  377. fat: Decimal(result.fat),
  378. protein: Decimal(result.protein),
  379. note: result.note,
  380. enteredBy: CarbsEntry.local,
  381. isFPU: result.isFPU,
  382. fpuID: result.fpuID?.uuidString
  383. )
  384. }
  385. }
  386. }
  387. func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry] {
  388. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  389. ofType: CarbEntryStored.self,
  390. onContext: coredataContext,
  391. predicate: NSPredicate.carbsNotYetUploadedToTidepool,
  392. key: "date",
  393. ascending: false
  394. )
  395. guard let carbEntries = results as? [CarbEntryStored] else {
  396. return []
  397. }
  398. return await coredataContext.perform {
  399. return carbEntries.map { result in
  400. CarbsEntry(
  401. id: result.id?.uuidString,
  402. createdAt: result.date ?? Date(),
  403. actualDate: result.date,
  404. carbs: Decimal(result.carbs),
  405. fat: nil,
  406. protein: nil,
  407. note: result.note,
  408. enteredBy: CarbsEntry.local,
  409. isFPU: nil,
  410. fpuID: nil
  411. )
  412. }
  413. }
  414. }
  415. }