Skip to content

Commit d737086

Browse files
authored
Merge pull request #691 from tidepool-org/noah/LOOP-4953/favorite-food-insights
[LOOP-4953] Favorite Food Insights
2 parents 4fa7686 + 9ca5088 commit d737086

31 files changed

+1225
-173
lines changed

Loop.xcodeproj/project.pbxproj

Lines changed: 71 additions & 11 deletions
Large diffs are not rendered by default.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// Character+IsEmoji.swift
3+
// Loop
4+
//
5+
// Created by Noah Brauner on 8/6/24.
6+
// Copyright © 2024 LoopKit Authors. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension Character {
12+
public var isEmoji: Bool {
13+
unicodeScalars.contains(where: { $0.properties.isEmoji })
14+
}
15+
}

Loop/Managers/AnalyticsServicesManager.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ final class AnalyticsServicesManager {
166166
logEvent("CGM Added", withProperties: ["identifier" : identifier])
167167
}
168168

169-
func didAddCarbs(source: String, amount: Double, inSession: Bool = false) {
170-
logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)"], outOfSession: inSession)
169+
func didAddCarbs(source: String, amount: Double, isFavoriteFood: Bool = false, inSession: Bool = false) {
170+
logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)", "isFavoriteFood": isFavoriteFood], outOfSession: inSession)
171171
}
172172

173173
func didRetryBolus() {

Loop/Managers/LoopDataManager.swift

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1127,12 +1127,111 @@ extension LoopDataManager: CarbEntryViewModelDelegate {
11271127
var defaultAbsorptionTimes: DefaultAbsorptionTimes {
11281128
LoopCoreConstants.defaultCarbAbsorptionTimes
11291129
}
1130-
11311130
func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample] {
11321131
try await glucoseStore.getGlucoseSamples(start: start, end: end)
11331132
}
11341133
}
11351134

1135+
extension LoopDataManager: FavoriteFoodInsightsViewModelDelegate {
1136+
func selectedFavoriteFoodLastEaten(_ favoriteFood: StoredFavoriteFood) async throws -> Date? {
1137+
try await carbStore.getCarbEntries(start: nil, end: nil, dateAscending: false, fetchLimit: 1, with: favoriteFood.id).first?.startDate
1138+
}
1139+
1140+
1141+
func getFavoriteFoodCarbEntries(_ favoriteFood: StoredFavoriteFood) async throws -> [LoopKit.StoredCarbEntry] {
1142+
try await carbStore.getCarbEntries(start: nil, end: nil, dateAscending: false, fetchLimit: nil, with: favoriteFood.id)
1143+
}
1144+
1145+
func getHistoricalChartsData(start: Date, end: Date) async throws -> HistoricalChartsData {
1146+
// Need to get insulin data from any active doses that might affect this time range
1147+
var dosesStart = start.addingTimeInterval(-InsulinMath.defaultInsulinActivityDuration)
1148+
let doses = try await doseStore.getNormalizedDoseEntries(
1149+
start: dosesStart,
1150+
end: end
1151+
).map { $0.simpleDose(with: insulinModel(for: $0.insulinType)) }
1152+
1153+
dosesStart = doses.map { $0.startDate }.min() ?? dosesStart
1154+
1155+
let basal = try await settingsProvider.getBasalHistory(startDate: dosesStart, endDate: end)
1156+
1157+
let carbEntries = try await carbStore.getCarbEntries(start: start, end: end)
1158+
1159+
let carbRatio = try await settingsProvider.getCarbRatioHistory(startDate: start, endDate: end)
1160+
1161+
let glucose = try await glucoseStore.getGlucoseSamples(start: start, end: end)
1162+
1163+
let sensitivityStart = min(start, dosesStart)
1164+
1165+
let sensitivity = try await settingsProvider.getInsulinSensitivityHistory(startDate: sensitivityStart, endDate: end)
1166+
1167+
let overrides = temporaryPresetsManager.overrideHistory.getOverrideHistory(startDate: sensitivityStart, endDate: end)
1168+
1169+
guard !sensitivity.isEmpty else {
1170+
throw LoopError.configurationError(.insulinSensitivitySchedule)
1171+
}
1172+
1173+
let sensitivityWithOverrides = overrides.applySensitivity(over: sensitivity)
1174+
1175+
guard !basal.isEmpty else {
1176+
throw LoopError.configurationError(.basalRateSchedule)
1177+
}
1178+
let basalWithOverrides = overrides.applyBasal(over: basal)
1179+
1180+
guard !carbRatio.isEmpty else {
1181+
throw LoopError.configurationError(.carbRatioSchedule)
1182+
}
1183+
let carbRatioWithOverrides = overrides.applyCarbRatio(over: carbRatio)
1184+
1185+
let carbModel: CarbAbsorptionModel = FeatureFlags.nonlinearCarbModelEnabled ? .piecewiseLinear : .linear
1186+
1187+
// Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal
1188+
let annotatedDoses = doses.annotated(with: basalWithOverrides)
1189+
1190+
let insulinEffects = annotatedDoses.glucoseEffects(
1191+
insulinSensitivityHistory: sensitivityWithOverrides,
1192+
from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta),
1193+
to: nil)
1194+
1195+
// ICE
1196+
let insulinCounteractionEffects = glucose.counteractionEffects(to: insulinEffects)
1197+
1198+
// Carb Effects
1199+
let carbStatus = carbEntries.map(
1200+
to: insulinCounteractionEffects,
1201+
carbRatio: carbRatioWithOverrides,
1202+
insulinSensitivity: sensitivityWithOverrides
1203+
)
1204+
1205+
let carbEffects = carbStatus.dynamicGlucoseEffects(
1206+
from: end,
1207+
to: end.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration),
1208+
carbRatios: carbRatioWithOverrides,
1209+
insulinSensitivities: sensitivityWithOverrides,
1210+
absorptionModel: carbModel.model
1211+
)
1212+
1213+
let carbAbsorptionReview = CarbAbsorptionReview(
1214+
carbEntries: carbEntries,
1215+
carbStatuses: carbStatus,
1216+
effectsVelocities: insulinCounteractionEffects,
1217+
carbEffects: carbEffects
1218+
)
1219+
1220+
let trimmedDoses = annotatedDoses.filterDateRange(start, end)
1221+
let trimmedIOBValues = annotatedDoses.insulinOnBoardTimeline().filterDateRange(start, end)
1222+
1223+
let historicalChartsData = HistoricalChartsData(
1224+
glucoseValues: glucose,
1225+
carbEntries: carbEntries,
1226+
doses: trimmedDoses,
1227+
iobValues: trimmedIOBValues,
1228+
carbAbsorptionReview: carbAbsorptionReview
1229+
)
1230+
1231+
return historicalChartsData
1232+
}
1233+
}
1234+
11361235
extension LoopDataManager: ManualDoseViewModelDelegate {
11371236
var pumpInsulinType: InsulinType? {
11381237
deliveryDelegate?.pumpInsulinType

Loop/Managers/Store Protocols/CarbStoreProtocol.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import HealthKit
1111

1212
protocol CarbStoreProtocol: AnyObject {
1313

14-
func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, with favoriteFoodID: String?) async throws -> [StoredCarbEntry]
14+
func getCarbEntries(start: Date?, end: Date?, dateAscending: Bool, fetchLimit: Int?, with favoriteFoodID: String?) async throws -> [StoredCarbEntry]
1515

1616
func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry) async throws -> StoredCarbEntry
1717

@@ -23,7 +23,7 @@ protocol CarbStoreProtocol: AnyObject {
2323

2424
extension CarbStoreProtocol {
2525
func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] {
26-
try await getCarbEntries(start: start, end: end, dateAscending: true, with: nil)
26+
try await getCarbEntries(start: start, end: end, dateAscending: true, fetchLimit: nil, with: nil)
2727
}
2828
}
2929

Loop/View Controllers/StatusTableViewController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1623,6 +1623,7 @@ final class StatusTableViewController: LoopChartsTableViewController {
16231623
therapySettingsViewModelDelegate: deviceManager,
16241624
delegate: self
16251625
)
1626+
viewModel.favoriteFoodInsightsDelegate = loopManager
16261627
let hostingController = DismissibleHostingController(
16271628
rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion)
16281629
.environmentObject(deviceManager.displayGlucosePreference)

Loop/View Models/BolusEntryViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ final class BolusEntryViewModel: ObservableObject {
382382
}
383383
if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) {
384384
self.dosingDecision.carbEntry = storedCarbEntry
385-
self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram()))
385+
self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram()), isFavoriteFood: storedCarbEntry.favoriteFoodID != nil)
386386
} else {
387387
self.presentAlert(.carbEntryPersistenceFailure)
388388
return false

Loop/View Models/CarbEntryViewModel.swift

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import HealthKit
1212
import Combine
1313
import LoopCore
1414
import LoopAlgorithm
15+
import os.log
1516

16-
protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate {
17+
protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate, FavoriteFoodInsightsViewModelDelegate {
1718
var defaultAbsorptionTimes: DefaultAbsorptionTimes { get }
1819
func scheduleOverrideEnabled(at date: Date) -> Bool
1920
func getGlucoseSamples(start: Date?, end: Date?) async throws -> [StoredGlucoseSample]
@@ -86,11 +87,24 @@ final class CarbEntryViewModel: ObservableObject {
8687
}
8788

8889
@Published var favoriteFoods = UserDefaults.standard.favoriteFoods
89-
@Published var selectedFavoriteFoodIndex = -1
90+
@Published var selectedFavoriteFoodIndex = -1 {
91+
willSet {
92+
self.selectedFavoriteFoodLastEaten = nil
93+
}
94+
}
9095
var selectedFavoriteFood: StoredFavoriteFood? {
9196
let foodExistsForIndex = 0..<favoriteFoods.count ~= selectedFavoriteFoodIndex
9297
return foodExistsForIndex ? favoriteFoods[selectedFavoriteFoodIndex] : nil
9398
}
99+
// Favorite Food Insights
100+
@Published var selectedFavoriteFoodLastEaten: Date? = nil
101+
lazy var relativeDateFormatter: RelativeDateTimeFormatter = {
102+
let formatter = RelativeDateTimeFormatter()
103+
formatter.unitsStyle = .full
104+
return formatter
105+
}()
106+
107+
private let log = OSLog(category: "CarbEntryViewModel")
94108

95109
weak var delegate: CarbEntryViewModelDelegate?
96110
weak var analyticsServicesManager: AnalyticsServicesManager?
@@ -127,22 +141,23 @@ final class CarbEntryViewModel: ObservableObject {
127141

128142
if let favoriteFoodIndex = favoriteFoods.firstIndex(where: { $0.id == originalCarbEntry.favoriteFoodID }) {
129143
self.selectedFavoriteFoodIndex = favoriteFoodIndex
144+
updateFavoriteFoodLastEatenDate(for: favoriteFoods[favoriteFoodIndex])
130145
}
131146

147+
observeFavoriteFoodIndexChange()
132148
observeLoopUpdates()
133149
}
134150

135151
var originalCarbEntry: StoredCarbEntry? = nil
136-
private var favoriteFood: FavoriteFood? = nil
137152

138153
private var updatedCarbEntry: NewCarbEntry? {
139154
if let quantity = carbsQuantity, quantity != 0 {
140-
if let o = originalCarbEntry, o.quantity.doubleValue(for: preferredCarbUnit) == quantity && o.startDate == time && o.foodType == foodType && o.absorptionTime == absorptionTime {
155+
let favoriteFoodID = selectedFavoriteFoodIndex == -1 ? nil : favoriteFoods[selectedFavoriteFoodIndex].id
156+
157+
if let o = originalCarbEntry, o.quantity.doubleValue(for: preferredCarbUnit) == quantity && o.startDate == time && o.foodType == foodType && o.absorptionTime == absorptionTime, o.favoriteFoodID == favoriteFoodID {
141158
return nil // No changes were made
142159
}
143160

144-
let favoriteFoodID = selectedFavoriteFoodIndex == -1 ? nil : favoriteFoods[selectedFavoriteFoodIndex].id
145-
146161
return NewCarbEntry(
147162
date: date,
148163
quantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity),
@@ -256,12 +271,15 @@ final class CarbEntryViewModel: ObservableObject {
256271

257272
private func favoriteFoodSelected(at index: Int) {
258273
self.absorptionEditIsProgrammatic = true
274+
// only updates carb entry fields if on new carb entry screen
259275
if index == -1 {
260-
self.carbsQuantity = 0
276+
if originalCarbEntry == nil {
277+
self.carbsQuantity = 0
278+
self.absorptionTime = defaultAbsorptionTimes.medium
279+
self.absorptionTimeWasEdited = false
280+
self.usesCustomFoodType = false
281+
}
261282
self.foodType = ""
262-
self.absorptionTime = defaultAbsorptionTimes.medium
263-
self.absorptionTimeWasEdited = false
264-
self.usesCustomFoodType = false
265283
}
266284
else {
267285
let food = favoriteFoods[index]
@@ -270,6 +288,23 @@ final class CarbEntryViewModel: ObservableObject {
270288
self.absorptionTime = food.absorptionTime
271289
self.absorptionTimeWasEdited = true
272290
self.usesCustomFoodType = true
291+
updateFavoriteFoodLastEatenDate(for: food)
292+
}
293+
}
294+
295+
private func updateFavoriteFoodLastEatenDate(for food: StoredFavoriteFood) {
296+
// Update favorite food insights last eaten date
297+
Task { @MainActor in
298+
do {
299+
if let lastEaten = try await delegate?.selectedFavoriteFoodLastEaten(food) {
300+
withAnimation(.default) {
301+
self.selectedFavoriteFoodLastEaten = lastEaten
302+
}
303+
}
304+
}
305+
catch {
306+
log.error("Failed to fetch last eaten date for favorite food: %{public}@, %{public}@", String(describing: selectedFavoriteFood), String(describing: error))
307+
}
273308
}
274309
}
275310

Loop/View Models/AddEditFavoriteFoodViewModel.swift renamed to Loop/View Models/FavoriteFoodAddEditViewModel.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// AddEditFavoriteFoodViewModel.swift
2+
// FavoriteFoodAddEditViewModel.swift
33
// Loop
44
//
55
// Created by Noah Brauner on 7/31/23.
@@ -10,7 +10,7 @@ import SwiftUI
1010
import LoopKit
1111
import HealthKit
1212

13-
final class AddEditFavoriteFoodViewModel: ObservableObject {
13+
final class FavoriteFoodAddEditViewModel: ObservableObject {
1414
enum Alert: Identifiable {
1515
var id: Self {
1616
return self
@@ -36,7 +36,7 @@ final class AddEditFavoriteFoodViewModel: ObservableObject {
3636
return minAbsorptionTime...maxAbsorptionTime
3737
}
3838

39-
@Published var alert: AddEditFavoriteFoodViewModel.Alert?
39+
@Published var alert: FavoriteFoodAddEditViewModel.Alert?
4040

4141
private let onSave: (NewFavoriteFood) -> ()
4242

@@ -57,8 +57,14 @@ final class AddEditFavoriteFoodViewModel: ObservableObject {
5757
init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> ()) {
5858
self.onSave = onSave
5959
self.carbsQuantity = carbsQuantity
60-
self.foodType = foodType
6160
self.absorptionTime = absorptionTime
61+
62+
// foodType of Apple 🍎 --> name: Apple, foodType: 🍎
63+
var name = foodType
64+
name.removeAll(where: \.isEmoji)
65+
name = name.trimmingCharacters(in: .whitespacesAndNewlines)
66+
self.foodType = foodType.filter(\.isEmoji)
67+
self.name = name
6268
}
6369

6470
var originalFavoriteFood: StoredFavoriteFood?

0 commit comments

Comments
 (0)