CarbsStorage.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import CoreData
  2. import Foundation
  3. import SwiftDate
  4. import Swinject
  5. protocol CarbsStoredDelegate: AnyObject {
  6. /*
  7. Informs the delegate that the Carbs Storage has updated Carbs
  8. */
  9. func carbsStorageHasUpdatedCarbs(_ carbsStorage: BaseCarbsStorage)
  10. }
  11. protocol CarbsObserver {
  12. func carbsDidUpdate(_ carbs: [CarbsEntry])
  13. }
  14. protocol CarbsStorage {
  15. var delegate: CarbsStoredDelegate? { get set }
  16. func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async
  17. func syncDate() -> Date
  18. func recent() -> [CarbsEntry]
  19. func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  20. func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  21. func deleteCarbs(at uniqueID: String, fpuID: String, complex: Bool)
  22. func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry]
  23. }
  24. final class BaseCarbsStorage: CarbsStorage, Injectable {
  25. private let processQueue = DispatchQueue(label: "BaseCarbsStorage.processQueue")
  26. @Injected() private var storage: FileStorage!
  27. @Injected() private var broadcaster: Broadcaster!
  28. @Injected() private var settings: SettingsManager!
  29. let coredataContext = CoreDataStack.shared.newTaskContext()
  30. public weak var delegate: CarbsStoredDelegate?
  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 saveCarbEquivalents(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
  40. await saveCarbsToCoreData(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
  41. // TODO: - Should we really use a delegate here? If yes, should we also use this for NS/TP?
  42. delegate?.carbsStorageHasUpdatedCarbs(self)
  43. }
  44. private func filterRemoteEntries(entries: [CarbsEntry]) async -> [CarbsEntry] {
  45. // Fetch only the date property from Core Data
  46. guard let existing24hCarbEntries = await CoreDataStack.shared.fetchEntitiesAsync(
  47. ofType: CarbEntryStored.self,
  48. onContext: coredataContext,
  49. predicate: NSPredicate.predicateForOneDayAgo,
  50. key: "date",
  51. ascending: false,
  52. batchSize: 50,
  53. propertiesToFetch: ["date", "objectID"]
  54. ) as? [[String: Any]] else {
  55. return entries
  56. }
  57. // Extract dates into a set for efficient lookup
  58. let existingTimestamps = Set(existing24hCarbEntries.compactMap { $0["date"] as? Date })
  59. // Remove all entries that have a matching date in existingTimestamps
  60. var filteredEntries = entries
  61. filteredEntries.removeAll { entry in
  62. let entryDate = entry.actualDate ?? entry.createdAt
  63. return existingTimestamps.contains(entryDate)
  64. }
  65. return filteredEntries
  66. }
  67. /**
  68. Calculates the duration for processing FPUs (fat and protein units) based on the FPUs and the time cap.
  69. - The function uses predefined rules to determine the duration based on the number of FPUs.
  70. - Ensures that the duration does not exceed the time cap.
  71. - Parameters:
  72. - fpus: The number of FPUs calculated from fat and protein.
  73. - timeCap: The maximum allowed duration.
  74. - Returns: The computed duration in hours.
  75. */
  76. private func calculateComputedDuration(fpus: Decimal, timeCap: Int) -> Int {
  77. switch fpus {
  78. case ..<2:
  79. return 3
  80. case 2 ..< 3:
  81. return 4
  82. case 3 ..< 4:
  83. return 5
  84. default:
  85. return timeCap
  86. }
  87. }
  88. /**
  89. Processes fat and protein entries to generate future carb equivalents, ensuring each equivalent is at least 1.0 grams.
  90. - The function calculates the equivalent carb dosage size and adjusts the interval to ensure each equivalent is at least 1.0 grams.
  91. - Creates future carb entries based on the adjusted carb equivalent size and interval.
  92. - Parameters:
  93. - entries: An array of `CarbsEntry` objects representing the carbohydrate entries to be processed.
  94. - fat: The amount of fat in the last entry.
  95. - protein: The amount of protein in the last entry.
  96. - createdAt: The creation date of the last entry.
  97. - Returns: A tuple containing the array of future carb entries and the total carb equivalents.
  98. */
  99. private func processFPU(
  100. entries _: [CarbsEntry],
  101. fat: Decimal,
  102. protein: Decimal,
  103. createdAt: Date,
  104. actualDate: Date?
  105. ) -> ([CarbsEntry], Decimal) {
  106. let interval = settings.settings.minuteInterval
  107. let timeCap = settings.settings.timeCap
  108. let adjustment = settings.settings.individualAdjustmentFactor
  109. let delay = settings.settings.delay
  110. let kcal = protein * 4 + fat * 9
  111. let carbEquivalents = (kcal / 10) * adjustment
  112. let fpus = carbEquivalents / 10
  113. var computedDuration = calculateComputedDuration(fpus: fpus, timeCap: timeCap)
  114. var carbEquivalentSize: Decimal = carbEquivalents / Decimal(computedDuration)
  115. carbEquivalentSize /= Decimal(60 / interval)
  116. if carbEquivalentSize < 1.0 {
  117. carbEquivalentSize = 1.0
  118. computedDuration = Int(carbEquivalents / carbEquivalentSize)
  119. }
  120. let roundedEquivalent: Double = round(Double(carbEquivalentSize * 10)) / 10
  121. carbEquivalentSize = Decimal(roundedEquivalent)
  122. var numberOfEquivalents = carbEquivalents / carbEquivalentSize
  123. var useDate = actualDate ?? createdAt
  124. let fpuID = UUID().uuidString
  125. var futureCarbArray = [CarbsEntry]()
  126. var firstIndex = true
  127. while carbEquivalents > 0, numberOfEquivalents > 0 {
  128. useDate = firstIndex ? useDate.addingTimeInterval(delay.minutes.timeInterval) : useDate
  129. .addingTimeInterval(interval.minutes.timeInterval)
  130. firstIndex = false
  131. let eachCarbEntry = CarbsEntry(
  132. id: UUID().uuidString,
  133. createdAt: createdAt,
  134. actualDate: useDate,
  135. carbs: carbEquivalentSize,
  136. fat: 0,
  137. protein: 0,
  138. note: nil,
  139. enteredBy: CarbsEntry.manual, 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. do {
  176. guard self.coredataContext.hasChanges else { return }
  177. try self.coredataContext.save()
  178. } catch {
  179. print(error.localizedDescription)
  180. }
  181. }
  182. }
  183. private func saveFPUToCoreDataAsBatchInsert(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  184. let commonFPUID =
  185. UUID() // all fpus should only get ONE id per batch insert to be able to delete them referencing the fpuID
  186. var entrySlice = ArraySlice(entries) // convert to ArraySlice
  187. let batchInsert = NSBatchInsertRequest(entity: CarbEntryStored.entity()) { (managedObject: NSManagedObject) -> Bool in
  188. guard let carbEntry = managedObject as? CarbEntryStored, let entry = entrySlice.popFirst(),
  189. let entryId = entry.id
  190. else {
  191. return true // return true to stop
  192. }
  193. carbEntry.date = entry.actualDate
  194. carbEntry.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
  195. carbEntry.id = UUID.init(uuidString: entryId)
  196. carbEntry.fpuID = commonFPUID
  197. carbEntry.isFPU = true
  198. carbEntry.isUploadedToNS = areFetchedFromRemote ? true : false
  199. return false // return false to continue
  200. }
  201. await coredataContext.perform {
  202. do {
  203. try self.coredataContext.execute(batchInsert)
  204. debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
  205. // Send notification for triggering a fetch in Home State Model to update the FPU Array
  206. Foundation.NotificationCenter.default.post(name: .didPerformBatchInsert, object: nil)
  207. } catch {
  208. debugPrint("Carbs Storage: \(DebuggingIdentifiers.failed) error while saving fpus to core data")
  209. }
  210. }
  211. }
  212. func syncDate() -> Date {
  213. Date().addingTimeInterval(-1.days.timeInterval)
  214. }
  215. func recent() -> [CarbsEntry] {
  216. storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self)?.reversed() ?? []
  217. }
  218. func deleteCarbs(at uniqueID: String, fpuID: String, complex: Bool) {
  219. processQueue.sync {
  220. var allValues = storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self) ?? []
  221. if fpuID != "" {
  222. if allValues.firstIndex(where: { $0.fpuID == fpuID }) == nil {
  223. debug(.default, "Didn't find any carb equivalents to delete. ID to search for: " + fpuID.description)
  224. } else {
  225. allValues.removeAll(where: { $0.fpuID == fpuID })
  226. storage.save(allValues, as: OpenAPS.Monitor.carbHistory)
  227. broadcaster.notify(CarbsObserver.self, on: processQueue) {
  228. $0.carbsDidUpdate(allValues)
  229. }
  230. }
  231. }
  232. if fpuID == "" || complex {
  233. if allValues.firstIndex(where: { $0.id == uniqueID }) == nil {
  234. debug(.default, "Didn't find any carb entries to delete. ID to search for: " + uniqueID.description)
  235. } else {
  236. allValues.removeAll(where: { $0.id == uniqueID })
  237. storage.save(allValues, as: OpenAPS.Monitor.carbHistory)
  238. broadcaster.notify(CarbsObserver.self, on: processQueue) {
  239. $0.carbsDidUpdate(allValues)
  240. }
  241. }
  242. }
  243. }
  244. }
  245. func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  246. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  247. ofType: CarbEntryStored.self,
  248. onContext: coredataContext,
  249. predicate: NSPredicate.carbsNotYetUploadedToNightscout,
  250. key: "date",
  251. ascending: false
  252. )
  253. guard let carbEntries = results as? [CarbEntryStored] else {
  254. return []
  255. }
  256. return await coredataContext.perform {
  257. return carbEntries.map { result in
  258. NightscoutTreatment(
  259. duration: nil,
  260. rawDuration: nil,
  261. rawRate: nil,
  262. absolute: nil,
  263. rate: nil,
  264. eventType: .nsCarbCorrection,
  265. createdAt: result.date,
  266. enteredBy: CarbsEntry.manual,
  267. bolus: nil,
  268. insulin: nil,
  269. notes: result.note,
  270. carbs: Decimal(result.carbs),
  271. fat: Decimal(result.fat),
  272. protein: Decimal(result.protein),
  273. foodType: result.note,
  274. targetTop: nil,
  275. targetBottom: nil,
  276. id: result.id?.uuidString
  277. )
  278. }
  279. }
  280. }
  281. func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  282. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  283. ofType: CarbEntryStored.self,
  284. onContext: coredataContext,
  285. predicate: NSPredicate.fpusNotYetUploadedToNightscout,
  286. key: "date",
  287. ascending: false
  288. )
  289. guard let fpuEntries = results as? [CarbEntryStored] else { return [] }
  290. return await coredataContext.perform {
  291. return fpuEntries.map { result in
  292. NightscoutTreatment(
  293. duration: nil,
  294. rawDuration: nil,
  295. rawRate: nil,
  296. absolute: nil,
  297. rate: nil,
  298. eventType: .nsCarbCorrection,
  299. createdAt: result.date,
  300. enteredBy: CarbsEntry.manual,
  301. bolus: nil,
  302. insulin: nil,
  303. carbs: Decimal(result.carbs),
  304. fat: Decimal(result.fat),
  305. protein: Decimal(result.protein),
  306. foodType: result.note,
  307. targetTop: nil,
  308. targetBottom: nil,
  309. id: result.fpuID?.uuidString
  310. )
  311. }
  312. }
  313. }
  314. func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry] {
  315. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  316. ofType: CarbEntryStored.self,
  317. onContext: coredataContext,
  318. predicate: NSPredicate.carbsNotYetUploadedToHealth,
  319. key: "date",
  320. ascending: false
  321. )
  322. guard let carbEntries = results as? [CarbEntryStored] else {
  323. return []
  324. }
  325. return await coredataContext.perform {
  326. return carbEntries.map { result in
  327. CarbsEntry(
  328. id: result.id?.uuidString,
  329. createdAt: result.date ?? Date(),
  330. actualDate: result.date,
  331. carbs: Decimal(result.carbs),
  332. fat: Decimal(result.fat),
  333. protein: Decimal(result.protein),
  334. note: result.note,
  335. enteredBy: "Trio",
  336. isFPU: result.isFPU,
  337. fpuID: result.fpuID?.uuidString
  338. )
  339. }
  340. }
  341. }
  342. }