From 4ac542b698048fbec10f349bdaa3c377485b7cc4 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Tue, 17 Dec 2024 00:28:43 +0100 Subject: [PATCH 1/8] toothbrushing as seconds per day MVP of #88 --- BeeKit/HeathKit/HealthKitConfig.swift | 3 +++ BeeKit/HeathKit/HealthKitMetric.swift | 1 + .../ToothbrushingHealthKitMetric.swift | 21 +++++++++++++++++++ BeeSwift.xcodeproj/project.pbxproj | 4 ++++ 4 files changed, 29 insertions(+) create mode 100644 BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift diff --git a/BeeKit/HeathKit/HealthKitConfig.swift b/BeeKit/HeathKit/HealthKitConfig.swift index 73ad41fa..d3f0c513 100644 --- a/BeeKit/HeathKit/HealthKitConfig.swift +++ b/BeeKit/HeathKit/HealthKitConfig.swift @@ -51,6 +51,9 @@ public enum HealthKitConfig { QuantityHealthKitMetric(humanText: "Vitamin K", databaseString: "dietaryVitaminK", category: .Nutrition, hkQuantityTypeIdentifier: .dietaryVitaminK), QuantityHealthKitMetric(humanText: "Water", databaseString: "water", category: .Nutrition, hkQuantityTypeIdentifier: .dietaryWater), + // Self care + ToothbrushingHealthKitMetric.make(), + // Sleep TimeInBedHealthKitMetric(humanText: "Time in bed", databaseString: "timeInBed", category: .Sleep), TimeAsleepHealthKitMetric(humanText: "Time asleep", databaseString: "timeAsleep", category: .Sleep), diff --git a/BeeKit/HeathKit/HealthKitMetric.swift b/BeeKit/HeathKit/HealthKitMetric.swift index 7d6d326e..07796192 100644 --- a/BeeKit/HeathKit/HealthKitMetric.swift +++ b/BeeKit/HeathKit/HealthKitMetric.swift @@ -12,6 +12,7 @@ public enum HealthKitCategory : String, CaseIterable { case Heart = "Heart" case Mindfulness = "Mindfulness" case Nutrition = "Nutrition" + case SelfCare = "Self Care" case Sleep = "Sleep" case Other = "Other Data" } diff --git a/BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift b/BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift new file mode 100644 index 00000000..7c73359b --- /dev/null +++ b/BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift @@ -0,0 +1,21 @@ +import Foundation +import HealthKit + +class ToothbrushingHealthKitMetric : CategoryHealthKitMetric { + private init(humanText: String, + databaseString: String, + category: HealthKitCategory) { + super.init(humanText: humanText, databaseString: databaseString, category: category, + hkSampleType: HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)!) + } + + override func units(healthStore : HKHealthStore) async throws -> HKUnit { + HKUnit.second() + } + + static func make() -> ToothbrushingHealthKitMetric { + .init(humanText: "Teethbrushing (in seconds per day)", + databaseString: HKCategoryTypeIdentifier.toothbrushingEvent.rawValue, + category: HealthKitCategory.SelfCare) + } +} diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 1d26997c..78558fb8 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 9B65F2322CFA6427009674A7 /* DeeplinkGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */; }; + 9B1DCA5B2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */; }; 9B8CA57D24B120CA009C86C2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */; }; 9BFB27E92CFE770F0056D10D /* FreshnessIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */; }; A10D4E931B07948500A72D29 /* DatapointsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10D4E921B07948500A72D29 /* DatapointsTableView.swift */; }; @@ -217,6 +218,7 @@ /* Begin PBXFileReference section */ 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkGenerator.swift; sourceTree = ""; }; + 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingHealthKitMetric.swift; sourceTree = ""; }; 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreshnessIndicatorView.swift; sourceTree = ""; }; A10D4E921B07948500A72D29 /* DatapointsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatapointsTableView.swift; sourceTree = ""; }; @@ -621,6 +623,7 @@ E4E6426E290E27CB004F3EA9 /* HeathKit */ = { isa = PBXGroup; children = ( + 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */, A1E618FF1E86980900D8ED93 /* HealthKitConfig.swift */, E4E642832910C442004F3EA9 /* CategoryHealthKitMetric.swift */, E4E642872910D055004F3EA9 /* MindfulSessionHealthKitMetric.swift */, @@ -1071,6 +1074,7 @@ E45470282B60E24500EE648B /* Daystamp.swift in Sources */, E458C8042AD11BC3000DCA5C /* SignedRequestManager.swift in Sources */, E458C8162AD11CA2000DCA5C /* HealthKitError.swift in Sources */, + 9B1DCA5B2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift in Sources */, E4B0A33128C194C900055EA7 /* AddDataIntents.intentdefinition in Sources */, E458C8132AD11C94000DCA5C /* HealthKitMetric.swift in Sources */, E458C80F2AD11C80000DCA5C /* HealthKitConfig.swift in Sources */, From c20ae288185bf01d8b54450c37fcc21ada2c3a9f Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Wed, 18 Dec 2024 09:46:12 +0100 Subject: [PATCH 2/8] also toothbrushing in sessions per day --- BeeKit/HeathKit/HealthKitConfig.swift | 1 + .../ToothbrushingHealthKitMetric.swift | 36 +++++++++ ...ToothbrushingSessionsHealthKitMetric.swift | 75 +++++++++++++++++++ .../ToothbrushingHealthKitMetric.swift | 21 ------ BeeSwift.xcodeproj/project.pbxproj | 14 +++- 5 files changed, 125 insertions(+), 22 deletions(-) create mode 100644 BeeKit/HeathKit/Toothbrushing/ToothbrushingHealthKitMetric.swift create mode 100644 BeeKit/HeathKit/Toothbrushing/ToothbrushingSessionsHealthKitMetric.swift delete mode 100644 BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift diff --git a/BeeKit/HeathKit/HealthKitConfig.swift b/BeeKit/HeathKit/HealthKitConfig.swift index d3f0c513..8a178c50 100644 --- a/BeeKit/HeathKit/HealthKitConfig.swift +++ b/BeeKit/HeathKit/HealthKitConfig.swift @@ -53,6 +53,7 @@ public enum HealthKitConfig { // Self care ToothbrushingHealthKitMetric.make(), + ToothbrushingSessionsHealthKitMetric.make(), // Sleep TimeInBedHealthKitMetric(humanText: "Time in bed", databaseString: "timeInBed", category: .Sleep), diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingHealthKitMetric.swift new file mode 100644 index 00000000..c505babb --- /dev/null +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingHealthKitMetric.swift @@ -0,0 +1,36 @@ +import Foundation +import HealthKit + +/// tracks toothbrushing, in number of seconds per day (daystamp) +class ToothbrushingHealthKitMetric: CategoryHealthKitMetric { + private static let healthkitMetric = ["toothbrushing", "seconds-per-day"].joined(separator: "|") + + private init(humanText: String, + databaseString: String, + category: HealthKitCategory) { + super.init(humanText: humanText, + databaseString: databaseString, + category: category, + hkSampleType: HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)!) + } + + override func units(healthStore : HKHealthStore) async throws -> HKUnit { + HKUnit.second() + } + + static func make() -> ToothbrushingHealthKitMetric { + .init(humanText: "Teethbrushing (in seconds per day)", + databaseString: healthkitMetric, + category: HealthKitCategory.SelfCare) + } + + override func recentDataPoints(days: Int, deadline: Int, healthStore: HKHealthStore) async throws -> [any BeeDataPoint] { + try await super.recentDataPoints(days: days, deadline: deadline, healthStore: healthStore) + .map { + NewDataPoint(requestid: $0.requestid, + daystamp: $0.daystamp, + value: $0.value, + comment: "Auto-entered via Apple Health (\(Self.healthkitMetric))") + } + } +} diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingSessionsHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingSessionsHealthKitMetric.swift new file mode 100644 index 00000000..cb68cbf7 --- /dev/null +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingSessionsHealthKitMetric.swift @@ -0,0 +1,75 @@ +// Part of BeeSwift. Copyright Beeminder + +import Foundation +import HealthKit + +/// tracks toothbrushing, in number of sessions per day (daystamp) +class ToothbrushingSessionsHealthKitMetric: CategoryHealthKitMetric { + private static let healthkitMetric = ["toothbrushing", "sessions-per-day"].joined(separator: "|") + + private init(humanText: String, + databaseString: String, + category: HealthKitCategory) { + super.init(humanText: humanText, + databaseString: databaseString, + category: category, + hkSampleType: HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)!) + } + + override func units(healthStore : HKHealthStore) async throws -> HKUnit { + .count() + } + + static func make() -> ToothbrushingSessionsHealthKitMetric { + .init(humanText: "Teethbrushing (in sessions per day)", + databaseString: healthkitMetric, + category: HealthKitCategory.SelfCare) + } + + override func recentDataPoints(days: Int, deadline: Int, healthStore: HKHealthStore) async throws -> [any BeeDataPoint] { + let todayDaystamp = Daystamp.now(deadline: deadline) + let startDaystamp = todayDaystamp - days + + let predicate = HKQuery.predicateForSamples(withStart: startDaystamp.start(deadline: deadline), + end: todayDaystamp.end(deadline: deadline)) + + let samples = try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation<[HKSample], Error>) in + let query = HKSampleQuery(sampleType: sampleType(), + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)], + resultsHandler: { (query, samples, error) in + if let error { + continuation.resume(throwing: error) + } else if let samples { + continuation.resume(returning: samples) + } else { + continuation.resume(throwing: HealthKitError("HKSampleQuery did not return samples")) + } + }) + healthStore.execute(query) + }) + .compactMap { $0 as? HKCategorySample } + + let calendar = Calendar.autoupdatingCurrent + let groupedByDay = Dictionary(grouping: samples, by: { sample in + calendar.startOfDay(for: sample.startDate) + }) + + let dailyCounts = groupedByDay + .map { ($0, $1.count) } + .sorted { $0.0 < $1.0 } + + let datapoints = dailyCounts.map({ (date, numberOfEntries) in + let daystamp = Daystamp(fromDate: date, deadline: deadline) + let requestID = "apple-heath-" + daystamp.description + + return NewDataPoint(requestid: requestID, + daystamp: daystamp, + value: NSNumber(value: numberOfEntries), + comment: "Auto-entered via Apple Health (\(Self.healthkitMetric))") + }) + + return datapoints + } +} diff --git a/BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift b/BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift deleted file mode 100644 index 7c73359b..00000000 --- a/BeeKit/HeathKit/ToothbrushingHealthKitMetric.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import HealthKit - -class ToothbrushingHealthKitMetric : CategoryHealthKitMetric { - private init(humanText: String, - databaseString: String, - category: HealthKitCategory) { - super.init(humanText: humanText, databaseString: databaseString, category: category, - hkSampleType: HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)!) - } - - override func units(healthStore : HKHealthStore) async throws -> HKUnit { - HKUnit.second() - } - - static func make() -> ToothbrushingHealthKitMetric { - .init(humanText: "Teethbrushing (in seconds per day)", - databaseString: HKCategoryTypeIdentifier.toothbrushingEvent.rawValue, - category: HealthKitCategory.SelfCare) - } -} diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 78558fb8..94a510b4 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 9B65F2322CFA6427009674A7 /* DeeplinkGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */; }; 9B1DCA5B2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */; }; + 9B7D44662D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7D44652D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift */; }; 9B8CA57D24B120CA009C86C2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */; }; 9BFB27E92CFE770F0056D10D /* FreshnessIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */; }; A10D4E931B07948500A72D29 /* DatapointsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10D4E921B07948500A72D29 /* DatapointsTableView.swift */; }; @@ -219,6 +220,7 @@ /* Begin PBXFileReference section */ 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkGenerator.swift; sourceTree = ""; }; 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingHealthKitMetric.swift; sourceTree = ""; }; + 9B7D44652D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingSessionsHealthKitMetric.swift; sourceTree = ""; }; 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreshnessIndicatorView.swift; sourceTree = ""; }; A10D4E921B07948500A72D29 /* DatapointsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatapointsTableView.swift; sourceTree = ""; }; @@ -409,6 +411,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9B7D44642D12C2F3003B62B1 /* Toothbrushing */ = { + isa = PBXGroup; + children = ( + 9B7D44652D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift */, + 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */, + ); + path = Toothbrushing; + sourceTree = ""; + }; A106AD8B1AF1F62800C434E8 /* Managers */ = { isa = PBXGroup; children = ( @@ -623,7 +634,7 @@ E4E6426E290E27CB004F3EA9 /* HeathKit */ = { isa = PBXGroup; children = ( - 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */, + 9B7D44642D12C2F3003B62B1 /* Toothbrushing */, A1E618FF1E86980900D8ED93 /* HealthKitConfig.swift */, E4E642832910C442004F3EA9 /* CategoryHealthKitMetric.swift */, E4E642872910D055004F3EA9 /* MindfulSessionHealthKitMetric.swift */, @@ -1105,6 +1116,7 @@ E46071012B451FA400305DB4 /* BeeminderModel.xcdatamodeld in Sources */, E458C80D2AD11C64000DCA5C /* Crypto.swift in Sources */, E458C8012AD11BB3000DCA5C /* RequestManager.swift in Sources */, + 9B7D44662D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 236931729e2f6a325c742e8ee100a232dbfe00bc Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Wed, 18 Dec 2024 09:47:32 +0100 Subject: [PATCH 3/8] removeHKMVC crashes force unwrapping goal.humanizedAutodata --- BeeSwift/Settings/RemoveHKMetricViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BeeSwift/Settings/RemoveHKMetricViewController.swift b/BeeSwift/Settings/RemoveHKMetricViewController.swift index 29df4edc..9ab3b68f 100644 --- a/BeeSwift/Settings/RemoveHKMetricViewController.swift +++ b/BeeSwift/Settings/RemoveHKMetricViewController.swift @@ -48,7 +48,7 @@ class RemoveHKMetricViewController: UIViewController { attrString.append(NSMutableAttributedString(string: "\(self.goal.slug)\n", attributes: [NSAttributedString.Key.font: UIFont.beeminder.defaultBoldFont])) - attrString.append(NSMutableAttributedString(string: "This goal obtains its data from Apple Health (\(self.goal.humanizedAutodata!)). You can disconnect the goal with the button below.", + attrString.append(NSMutableAttributedString(string: "This goal obtains its data from Apple Health (\(self.goal.humanizedAutodata ?? self.goal.healthKitMetric ?? "unknown metric")). You can disconnect the goal with the button below.", attributes: [NSAttributedString.Key.font: UIFont.beeminder.defaultFontLight.withSize(Constants.defaultFontSize)])) return attrString }() From dd45a1976ddcf1ee8fb5a274394e1f242fc4a4d9 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Wed, 18 Dec 2024 09:53:25 +0100 Subject: [PATCH 4/8] clean code --- BeeKit/HeathKit/HealthKitConfig.swift | 4 ++-- ... => ToothbrushingDailyMinutesHealthKitMetric.swift} | 10 +++++----- ...=> ToothbrushingDailySessionsHealthKitMetric.swift} | 4 ++-- BeeSwift.xcodeproj/project.pbxproj | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) rename BeeKit/HeathKit/Toothbrushing/{ToothbrushingHealthKitMetric.swift => ToothbrushingDailyMinutesHealthKitMetric.swift} (74%) rename BeeKit/HeathKit/Toothbrushing/{ToothbrushingSessionsHealthKitMetric.swift => ToothbrushingDailySessionsHealthKitMetric.swift} (95%) diff --git a/BeeKit/HeathKit/HealthKitConfig.swift b/BeeKit/HeathKit/HealthKitConfig.swift index 8a178c50..00c7ff6f 100644 --- a/BeeKit/HeathKit/HealthKitConfig.swift +++ b/BeeKit/HeathKit/HealthKitConfig.swift @@ -52,8 +52,8 @@ public enum HealthKitConfig { QuantityHealthKitMetric(humanText: "Water", databaseString: "water", category: .Nutrition, hkQuantityTypeIdentifier: .dietaryWater), // Self care - ToothbrushingHealthKitMetric.make(), - ToothbrushingSessionsHealthKitMetric.make(), + ToothbrushingDailyMinutesHealthKitMetric.make(), + ToothbrushingDailySessionsHealthKitMetric.make(), // Sleep TimeInBedHealthKitMetric(humanText: "Time in bed", databaseString: "timeInBed", category: .Sleep), diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift similarity index 74% rename from BeeKit/HeathKit/Toothbrushing/ToothbrushingHealthKitMetric.swift rename to BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift index c505babb..fc6e906e 100644 --- a/BeeKit/HeathKit/Toothbrushing/ToothbrushingHealthKitMetric.swift +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift @@ -1,9 +1,9 @@ import Foundation import HealthKit -/// tracks toothbrushing, in number of seconds per day (daystamp) -class ToothbrushingHealthKitMetric: CategoryHealthKitMetric { - private static let healthkitMetric = ["toothbrushing", "seconds-per-day"].joined(separator: "|") +/// tracks toothbrushing, in number of (decimal) minutes per day (daystamp) +class ToothbrushingDailyMinutesHealthKitMetric: CategoryHealthKitMetric { + private static let healthkitMetric = ["toothbrushing", "minutes-per-day"].joined(separator: "|") private init(humanText: String, databaseString: String, @@ -18,7 +18,7 @@ class ToothbrushingHealthKitMetric: CategoryHealthKitMetric { HKUnit.second() } - static func make() -> ToothbrushingHealthKitMetric { + static func make() -> ToothbrushingDailyMinutesHealthKitMetric { .init(humanText: "Teethbrushing (in seconds per day)", databaseString: healthkitMetric, category: HealthKitCategory.SelfCare) @@ -29,7 +29,7 @@ class ToothbrushingHealthKitMetric: CategoryHealthKitMetric { .map { NewDataPoint(requestid: $0.requestid, daystamp: $0.daystamp, - value: $0.value, + value: NSNumber(value: $0.value.doubleValue / 60), comment: "Auto-entered via Apple Health (\(Self.healthkitMetric))") } } diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingSessionsHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift similarity index 95% rename from BeeKit/HeathKit/Toothbrushing/ToothbrushingSessionsHealthKitMetric.swift rename to BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift index cb68cbf7..436bf7d1 100644 --- a/BeeKit/HeathKit/Toothbrushing/ToothbrushingSessionsHealthKitMetric.swift +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift @@ -4,7 +4,7 @@ import Foundation import HealthKit /// tracks toothbrushing, in number of sessions per day (daystamp) -class ToothbrushingSessionsHealthKitMetric: CategoryHealthKitMetric { +class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { private static let healthkitMetric = ["toothbrushing", "sessions-per-day"].joined(separator: "|") private init(humanText: String, @@ -20,7 +20,7 @@ class ToothbrushingSessionsHealthKitMetric: CategoryHealthKitMetric { .count() } - static func make() -> ToothbrushingSessionsHealthKitMetric { + static func make() -> ToothbrushingDailySessionsHealthKitMetric { .init(humanText: "Teethbrushing (in sessions per day)", databaseString: healthkitMetric, category: HealthKitCategory.SelfCare) diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 94a510b4..10cdf870 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -414,8 +414,8 @@ 9B7D44642D12C2F3003B62B1 /* Toothbrushing */ = { isa = PBXGroup; children = ( - 9B7D44652D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift */, - 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */, + 9B7D44652D12C304003B62B1 /* ToothbrushingDailySessionsHealthKitMetric.swift */, + 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingDailyMinutesHealthKitMetric.swift */, ); path = Toothbrushing; sourceTree = ""; @@ -1085,7 +1085,7 @@ E45470282B60E24500EE648B /* Daystamp.swift in Sources */, E458C8042AD11BC3000DCA5C /* SignedRequestManager.swift in Sources */, E458C8162AD11CA2000DCA5C /* HealthKitError.swift in Sources */, - 9B1DCA5B2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift in Sources */, + 9B1DCA5B2D10EA76006A64D9 /* ToothbrushingDailyMinutesHealthKitMetric.swift in Sources */, E4B0A33128C194C900055EA7 /* AddDataIntents.intentdefinition in Sources */, E458C8132AD11C94000DCA5C /* HealthKitMetric.swift in Sources */, E458C80F2AD11C80000DCA5C /* HealthKitConfig.swift in Sources */, @@ -1116,7 +1116,7 @@ E46071012B451FA400305DB4 /* BeeminderModel.xcdatamodeld in Sources */, E458C80D2AD11C64000DCA5C /* Crypto.swift in Sources */, E458C8012AD11BB3000DCA5C /* RequestManager.swift in Sources */, - 9B7D44662D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift in Sources */, + 9B7D44662D12C304003B62B1 /* ToothbrushingDailySessionsHealthKitMetric.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 41518a1a5dad5ce5592b5133d205ff56bc62f873 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Dec 2024 13:05:40 +0100 Subject: [PATCH 5/8] Update ToothbrushingDailyMinutesHealthKitMetric.swift --- .../ToothbrushingDailyMinutesHealthKitMetric.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift index fc6e906e..9c645e8b 100644 --- a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift @@ -23,13 +23,18 @@ class ToothbrushingDailyMinutesHealthKitMetric: CategoryHealthKitMetric { databaseString: healthkitMetric, category: HealthKitCategory.SelfCare) } + + override func valueInAppropriateUnits(rawValue: Double) -> Double { + // raw seconds into minutes + rawValue / 60 + } override func recentDataPoints(days: Int, deadline: Int, healthStore: HKHealthStore) async throws -> [any BeeDataPoint] { try await super.recentDataPoints(days: days, deadline: deadline, healthStore: healthStore) .map { NewDataPoint(requestid: $0.requestid, daystamp: $0.daystamp, - value: NSNumber(value: $0.value.doubleValue / 60), + value: $0.value, comment: "Auto-entered via Apple Health (\(Self.healthkitMetric))") } } From 11144a3685af31b630d2cf3cecea1ac986c0ccf8 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sat, 21 Dec 2024 13:08:24 +0100 Subject: [PATCH 6/8] no more self care --- BeeKit/HeathKit/HealthKitConfig.swift | 6 ++---- BeeKit/HeathKit/HealthKitMetric.swift | 1 - .../ToothbrushingDailyMinutesHealthKitMetric.swift | 14 ++++---------- ...ToothbrushingDailySessionsHealthKitMetric.swift | 12 +++--------- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/BeeKit/HeathKit/HealthKitConfig.swift b/BeeKit/HeathKit/HealthKitConfig.swift index 00c7ff6f..33df8e64 100644 --- a/BeeKit/HeathKit/HealthKitConfig.swift +++ b/BeeKit/HeathKit/HealthKitConfig.swift @@ -51,16 +51,14 @@ public enum HealthKitConfig { QuantityHealthKitMetric(humanText: "Vitamin K", databaseString: "dietaryVitaminK", category: .Nutrition, hkQuantityTypeIdentifier: .dietaryVitaminK), QuantityHealthKitMetric(humanText: "Water", databaseString: "water", category: .Nutrition, hkQuantityTypeIdentifier: .dietaryWater), - // Self care - ToothbrushingDailyMinutesHealthKitMetric.make(), - ToothbrushingDailySessionsHealthKitMetric.make(), - // Sleep TimeInBedHealthKitMetric(humanText: "Time in bed", databaseString: "timeInBed", category: .Sleep), TimeAsleepHealthKitMetric(humanText: "Time asleep", databaseString: "timeAsleep", category: .Sleep), // Other QuantityHealthKitMetric(humanText: "Time in Daylight", databaseString: "timeInDaylight", category: .Other, hkQuantityTypeIdentifier: .timeInDaylight), + ToothbrushingDailyMinutesHealthKitMetric(), + ToothbrushingDailySessionsHealthKitMetric(), ] } } diff --git a/BeeKit/HeathKit/HealthKitMetric.swift b/BeeKit/HeathKit/HealthKitMetric.swift index 07796192..7d6d326e 100644 --- a/BeeKit/HeathKit/HealthKitMetric.swift +++ b/BeeKit/HeathKit/HealthKitMetric.swift @@ -12,7 +12,6 @@ public enum HealthKitCategory : String, CaseIterable { case Heart = "Heart" case Mindfulness = "Mindfulness" case Nutrition = "Nutrition" - case SelfCare = "Self Care" case Sleep = "Sleep" case Other = "Other Data" } diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift index 9c645e8b..54cc518d 100644 --- a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailyMinutesHealthKitMetric.swift @@ -5,9 +5,9 @@ import HealthKit class ToothbrushingDailyMinutesHealthKitMetric: CategoryHealthKitMetric { private static let healthkitMetric = ["toothbrushing", "minutes-per-day"].joined(separator: "|") - private init(humanText: String, - databaseString: String, - category: HealthKitCategory) { + init(humanText: String = "Teethbrushing (in minutes per day)", + databaseString: String = ToothbrushingDailyMinutesHealthKitMetric.healthkitMetric, + category: HealthKitCategory = .Other) { super.init(humanText: humanText, databaseString: databaseString, category: category, @@ -18,17 +18,11 @@ class ToothbrushingDailyMinutesHealthKitMetric: CategoryHealthKitMetric { HKUnit.second() } - static func make() -> ToothbrushingDailyMinutesHealthKitMetric { - .init(humanText: "Teethbrushing (in seconds per day)", - databaseString: healthkitMetric, - category: HealthKitCategory.SelfCare) - } - override func valueInAppropriateUnits(rawValue: Double) -> Double { // raw seconds into minutes rawValue / 60 } - + override func recentDataPoints(days: Int, deadline: Int, healthStore: HKHealthStore) async throws -> [any BeeDataPoint] { try await super.recentDataPoints(days: days, deadline: deadline, healthStore: healthStore) .map { diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift index 436bf7d1..c3ec6233 100644 --- a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift @@ -7,9 +7,9 @@ import HealthKit class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { private static let healthkitMetric = ["toothbrushing", "sessions-per-day"].joined(separator: "|") - private init(humanText: String, - databaseString: String, - category: HealthKitCategory) { + init(humanText: String = "Teethbrushing (in sessions per day)", + databaseString: String = ToothbrushingDailySessionsHealthKitMetric.healthkitMetric, + category: HealthKitCategory = .Other) { super.init(humanText: humanText, databaseString: databaseString, category: category, @@ -20,12 +20,6 @@ class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { .count() } - static func make() -> ToothbrushingDailySessionsHealthKitMetric { - .init(humanText: "Teethbrushing (in sessions per day)", - databaseString: healthkitMetric, - category: HealthKitCategory.SelfCare) - } - override func recentDataPoints(days: Int, deadline: Int, healthStore: HKHealthStore) async throws -> [any BeeDataPoint] { let todayDaystamp = Daystamp.now(deadline: deadline) let startDaystamp = todayDaystamp - days From 6ee5cee07cca8f98cc651d32d4b56c009839fe62 Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Sun, 22 Dec 2024 12:10:08 +0100 Subject: [PATCH 7/8] clean code separating pieces of the "calculate data for toothbrushing daily sessions" integration such that they can be tested ... without mocking all of healthkit/healthstore --- ...brushingDailySessionsHealthKitMetric.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) mode change 100644 => 100755 BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift diff --git a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift old mode 100644 new mode 100755 index c3ec6233..4906c448 --- a/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift +++ b/BeeKit/HeathKit/Toothbrushing/ToothbrushingDailySessionsHealthKitMetric.swift @@ -6,6 +6,7 @@ import HealthKit /// tracks toothbrushing, in number of sessions per day (daystamp) class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { private static let healthkitMetric = ["toothbrushing", "sessions-per-day"].joined(separator: "|") + private static let hkSampleType = HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)! init(humanText: String = "Teethbrushing (in sessions per day)", databaseString: String = ToothbrushingDailySessionsHealthKitMetric.healthkitMetric, @@ -13,7 +14,7 @@ class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { super.init(humanText: humanText, databaseString: databaseString, category: category, - hkSampleType: HKObjectType.categoryType(forIdentifier: .toothbrushingEvent)!) + hkSampleType: Self.hkSampleType) } override func units(healthStore : HKHealthStore) async throws -> HKUnit { @@ -27,7 +28,17 @@ class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { let predicate = HKQuery.predicateForSamples(withStart: startDaystamp.start(deadline: deadline), end: todayDaystamp.end(deadline: deadline)) - let samples = try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation<[HKSample], Error>) in + let samples = try await queryHealthStore(healthStore, predicate: predicate) + + let dailyCounts = calculateDailyCounts(samples: samples) + + let datapoints = makeDatapoints(dailyCounts: dailyCounts, deadline: deadline) + + return datapoints + } + + func queryHealthStore(_ healthStore: HKHealthStore, predicate: NSPredicate) async throws -> [HKCategorySample] { + try await withCheckedThrowingContinuation({ (continuation: CheckedContinuation<[HKSample], Error>) in let query = HKSampleQuery(sampleType: sampleType(), predicate: predicate, limit: HKObjectQueryNoLimit, @@ -44,7 +55,9 @@ class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { healthStore.execute(query) }) .compactMap { $0 as? HKCategorySample } - + } + + func calculateDailyCounts(samples: [HKCategorySample]) -> [(Date, Int)] { let calendar = Calendar.autoupdatingCurrent let groupedByDay = Dictionary(grouping: samples, by: { sample in calendar.startOfDay(for: sample.startDate) @@ -54,6 +67,10 @@ class ToothbrushingDailySessionsHealthKitMetric: CategoryHealthKitMetric { .map { ($0, $1.count) } .sorted { $0.0 < $1.0 } + return dailyCounts + } + + func makeDatapoints(dailyCounts: [(Date, Int)], deadline: Int) -> [any BeeDataPoint] { let datapoints = dailyCounts.map({ (date, numberOfEntries) in let daystamp = Daystamp(fromDate: date, deadline: deadline) let requestID = "apple-heath-" + daystamp.description From 1695cc27d4376c6f06e473466c2d851585e2bb1c Mon Sep 17 00:00:00 2001 From: krugerk <4656811+krugerk@users.noreply.github.com> Date: Mon, 27 Jan 2025 13:47:49 +0100 Subject: [PATCH 8/8] wrong files mentioned in project file --- BeeSwift.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 10cdf870..6a3210ab 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -7,9 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 9B1CB1B02D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1CB1AF2D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift */; }; + 9B1CB1B12D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1CB1AE2D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift */; }; 9B65F2322CFA6427009674A7 /* DeeplinkGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */; }; - 9B1DCA5B2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */; }; - 9B7D44662D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7D44652D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift */; }; 9B8CA57D24B120CA009C86C2 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */; }; 9BFB27E92CFE770F0056D10D /* FreshnessIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */; }; A10D4E931B07948500A72D29 /* DatapointsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10D4E921B07948500A72D29 /* DatapointsTableView.swift */; }; @@ -218,9 +218,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 9B1CB1AE2D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingDailyMinutesHealthKitMetric.swift; sourceTree = ""; }; + 9B1CB1AF2D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingDailySessionsHealthKitMetric.swift; sourceTree = ""; }; 9B65F2312CFA6418009674A7 /* DeeplinkGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkGenerator.swift; sourceTree = ""; }; - 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingHealthKitMetric.swift; sourceTree = ""; }; - 9B7D44652D12C304003B62B1 /* ToothbrushingSessionsHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToothbrushingSessionsHealthKitMetric.swift; sourceTree = ""; }; 9B8CA57C24B120CA009C86C2 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 9BFB27E82CFE770F0056D10D /* FreshnessIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreshnessIndicatorView.swift; sourceTree = ""; }; A10D4E921B07948500A72D29 /* DatapointsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatapointsTableView.swift; sourceTree = ""; }; @@ -414,8 +414,8 @@ 9B7D44642D12C2F3003B62B1 /* Toothbrushing */ = { isa = PBXGroup; children = ( - 9B7D44652D12C304003B62B1 /* ToothbrushingDailySessionsHealthKitMetric.swift */, - 9B1DCA5A2D10EA76006A64D9 /* ToothbrushingDailyMinutesHealthKitMetric.swift */, + 9B1CB1AE2D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift */, + 9B1CB1AF2D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift */, ); path = Toothbrushing; sourceTree = ""; @@ -1085,10 +1085,11 @@ E45470282B60E24500EE648B /* Daystamp.swift in Sources */, E458C8042AD11BC3000DCA5C /* SignedRequestManager.swift in Sources */, E458C8162AD11CA2000DCA5C /* HealthKitError.swift in Sources */, - 9B1DCA5B2D10EA76006A64D9 /* ToothbrushingDailyMinutesHealthKitMetric.swift in Sources */, E4B0A33128C194C900055EA7 /* AddDataIntents.intentdefinition in Sources */, E458C8132AD11C94000DCA5C /* HealthKitMetric.swift in Sources */, E458C80F2AD11C80000DCA5C /* HealthKitConfig.swift in Sources */, + 9B1CB1B02D47B7D400CAF556 /* ToothbrushingDailySessionsHealthKitMetric.swift in Sources */, + 9B1CB1B12D47B7D400CAF556 /* ToothbrushingDailyMinutesHealthKitMetric.swift in Sources */, E458C8262AD11E12000DCA5C /* BSTextField.swift in Sources */, E458C8022AD11BB9000DCA5C /* CurrentUserManager.swift in Sources */, E458C8182AD11CAC000DCA5C /* TimeAsleepHealthKitMetric.swift in Sources */, @@ -1116,7 +1117,6 @@ E46071012B451FA400305DB4 /* BeeminderModel.xcdatamodeld in Sources */, E458C80D2AD11C64000DCA5C /* Crypto.swift in Sources */, E458C8012AD11BB3000DCA5C /* RequestManager.swift in Sources */, - 9B7D44662D12C304003B62B1 /* ToothbrushingDailySessionsHealthKitMetric.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };