diff --git a/Prisma.xcodeproj/project.pbxproj b/Prisma.xcodeproj/project.pbxproj index 68aa227..eba8deb 100644 --- a/Prisma.xcodeproj/project.pbxproj +++ b/Prisma.xcodeproj/project.pbxproj @@ -72,9 +72,10 @@ A9D83F962B083794000D0C78 /* SpeziFirebaseAccountStorage in Frameworks */ = {isa = PBXBuildFile; productRef = A9D83F952B083794000D0C78 /* SpeziFirebaseAccountStorage */; }; A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFE8A82ABE551400428242 /* AccountButton.swift */; }; A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */; }; - E4C766262B72D50500C1DEDA /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C766252B72D50500C1DEDA /* WebView.swift */; }; AC69903E2B6C5A2F00D92970 /* PrivacyControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC69903D2B6C5A2F00D92970 /* PrivacyControls.swift */; }; AC6990402B6C627100D92970 /* ToggleTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC69903F2B6C627100D92970 /* ToggleTestView.swift */; }; + E4C766262B72D50500C1DEDA /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C766252B72D50500C1DEDA /* WebView.swift */; }; + F83B7CBE2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */; }; F8AF6F9A2B5F2B1A0011C32D /* AppIcon-NoBG.png in Resources */ = {isa = PBXBuildFile; fileRef = F8AF6F992B5F2B1A0011C32D /* AppIcon-NoBG.png */; }; F8AF6F9F2B5F35400011C32D /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6F9E2B5F35400011C32D /* ChatView.swift */; }; F8AF6FA52B5F3AE70011C32D /* EventContextCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8AF6FA42B5F3AE70011C32D /* EventContextCard.swift */; }; @@ -152,9 +153,10 @@ A9720E422ABB68CC00872D23 /* AccountSetupHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSetupHeader.swift; sourceTree = ""; }; A9DFE8A82ABE551400428242 /* AccountButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountButton.swift; sourceTree = ""; }; A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSheet.swift; sourceTree = ""; }; - E4C766252B72D50500C1DEDA /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; AC69903D2B6C5A2F00D92970 /* PrivacyControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyControls.swift; sourceTree = ""; }; AC69903F2B6C627100D92970 /* ToggleTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleTestView.swift; sourceTree = ""; }; + E4C766252B72D50500C1DEDA /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; + F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PrismaStandard+TimeIndex.swift"; sourceTree = ""; }; F8AF6F992B5F2B1A0011C32D /* AppIcon-NoBG.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "AppIcon-NoBG.png"; sourceTree = ""; }; F8AF6F9E2B5F35400011C32D /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; F8AF6FA42B5F3AE70011C32D /* EventContextCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventContextCard.swift; sourceTree = ""; }; @@ -441,6 +443,7 @@ F8AF6FB82B5F72650011C32D /* PrismaStandard+HealthKit.swift */, F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */, F8AF6FB52B5F71460011C32D /* PrismaStandard+Extension.swift */, + F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */, F8AF6FBB2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift */, ); path = Standard; @@ -678,6 +681,7 @@ 2FE5DC5029EDD7FA004B9AB4 /* EventContextView.swift in Sources */, F8AF6FB62B5F71460011C32D /* PrismaStandard+Extension.swift in Sources */, 2F4E23832989D51F0013F3D9 /* PrismaTestingSetup.swift in Sources */, + F83B7CBE2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift in Sources */, 2FE5DC5329EDD7FA004B9AB4 /* Bundle+Questionnaire.swift in Sources */, 2FE5DC5129EDD7FA004B9AB4 /* PrismaTaskContext.swift in Sources */, F8AF6FBC2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift in Sources */, diff --git a/Prisma/Standard/PrismaStandard+HealthKit.swift b/Prisma/Standard/PrismaStandard+HealthKit.swift index 809a660..e366a81 100644 --- a/Prisma/Standard/PrismaStandard+HealthKit.swift +++ b/Prisma/Standard/PrismaStandard+HealthKit.swift @@ -1,5 +1,3 @@ -import FirebaseFirestore -import HealthKitOnFHIR // // This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project // @@ -8,29 +6,12 @@ import HealthKitOnFHIR // SPDX-License-Identifier: MIT // +import FirebaseFirestore +import HealthKitOnFHIR import ModelsR4 import SpeziFirestore import SpeziHealthKit -/* - - HKQuantityType(.vo2Max), - HKQuantityType(.heartRate), - HKQuantityType(.restingHeartRate), - HKQuantityType(.oxygenSaturation), - HKQuantityType(.respiratoryRate), - HKQuantityType(.walkingHeartRateAverage) - - - var includeVo2Max = true - var includeHeartRate = true - var includeRestingHeartRate = true - var includeOxygenSaturation = true - var includeRespiratoryRate = true - var includeWalkingHRAverage = true - */ - - extension PrismaStandard { func getSampleIdentifier(sample: HKSample) -> String? { switch sample { @@ -52,10 +33,10 @@ extension PrismaStandard { func add(sample: HKSample) async { let identifier: String if let id = getSampleIdentifier(sample: sample) { - print("Sample identifier: \(id)") + // print("Sample identifier: \(id)") identifier = id } else { - print("Unknown sample type") + print("Failed to upload HealtHkit sample. Unknown sample type: \(sample)") return } @@ -84,9 +65,9 @@ extension PrismaStandard { // convert the startDate of the HKSample to local time - let startDatetime = sample.startDate - let effectiveTimestamp = startDatetime.localISOFormat() - let endDatetime = sample.endDate.localISOFormat() + let timeIndex = constructTimeIndex(startDate: sample.startDate, endDate: sample.endDate) + let effectiveTimestamp = sample.startDate.localISOFormat() + let path: String // path = HEALTH_KIT_PATH/raw/YYYY-MM-DDThh:mm:ss.mss @@ -112,6 +93,7 @@ extension PrismaStandard { let encoder = FirebaseFirestore.Firestore.Encoder() var firestoreResource = try encoder.encode(resource) firestoreResource["device"] = deviceName + firestoreResource["time"] = timeIndex try await Firestore.firestore().document(path).setData(firestoreResource) } catch { print("Failed to set data in Firestore: \(error.localizedDescription)") diff --git a/Prisma/Standard/PrismaStandard+TimeIndex.swift b/Prisma/Standard/PrismaStandard+TimeIndex.swift new file mode 100644 index 0000000..5a6b5aa --- /dev/null +++ b/Prisma/Standard/PrismaStandard+TimeIndex.swift @@ -0,0 +1,121 @@ +// +// This source file is part of the Stanford Prisma Application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// +// Created by Matthew Jörke on 2/28/24. +// + +import Foundation + +func constructTimeIndex(startDate: Date, endDate: Date) -> [String: Any?] { + let calendar = Calendar.current + let startComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: startDate) + let endComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: endDate) + let isRange = startDate != endDate + + var timeIndex: [String: Any?] = [ + "range": isRange, + "timezone": startComponents.timeZone, + "datetime.start": startDate.localISOFormat(), + "datetime.end": endDate.localISOFormat() + ] + + addTimeIndexComponents(&timeIndex, dateComponents: startComponents, suffix: ".start") + + if isRange { + // only write end date and range if the sample is a range type + addTimeIndexComponents(&timeIndex, dateComponents: endComponents, suffix: ".end") + addTimeIndexRangeComponents(&timeIndex, startComponents: startComponents, endComponents: endComponents) + } + + return timeIndex +} + +func addTimeIndexComponents(_ timeIndex: inout [String: Any?], dateComponents: DateComponents, suffix: String) { + timeIndex["year" + suffix] = dateComponents.year + timeIndex["month" + suffix] = dateComponents.month + timeIndex["day" + suffix] = dateComponents.day + timeIndex["hour" + suffix] = dateComponents.hour + timeIndex["minute" + suffix] = dateComponents.minute + timeIndex["second" + suffix] = dateComponents.second + timeIndex["dayMinute" + suffix] = calculateDayMinute(hour: dateComponents.hour, minute: dateComponents.minute) + timeIndex["15minBucket" + suffix] = calculate15MinBucket(hour: dateComponents.hour, minute: dateComponents.minute) +} + +func addTimeIndexRangeComponents(_ timeIndex: inout [String: Any?], startComponents: DateComponents, endComponents: DateComponents) { + timeIndex["year.range"] = getRange( + start: startComponents.year, + end: endComponents.year, + maxValue: Int.max + ) + timeIndex["month.range"] = getRange( + start: startComponents.month, + end: endComponents.month, + maxValue: 12, + startValue: 1 // months are 1-indexed + ) + timeIndex["day.range"] = getRange( + start: startComponents.day, + end: endComponents.day, + maxValue: daysInMonth(month: startComponents.month, year: startComponents.year), + startValue: 1 // days are 1-indexed + ) + timeIndex["hour.range"] = getRange( + start: startComponents.hour, + end: endComponents.hour, + maxValue: 23 + ) + timeIndex["dayMinute.range"] = getRange( + start: calculateDayMinute(hour: startComponents.hour, minute: startComponents.minute), + end: calculateDayMinute(hour: endComponents.hour, minute: endComponents.minute), + maxValue: 1439 + ) + timeIndex["15minBucket.range"] = getRange( + start: calculate15MinBucket(hour: startComponents.hour, minute: startComponents.minute), + end: calculate15MinBucket(hour: endComponents.hour, minute: endComponents.minute), + maxValue: 95 + ) + + // Minute and second ranges are not likely to be accurate since they often will fill the whole range. + // We will also never query on individual minutes or seconds worth of data. +} + +// swiftlint:disable discouraged_optional_collection +func getRange(start: Int?, end: Int?, maxValue: Int?, startValue: Int = 0) -> [Int]? { + guard let startInt = start, let endInt = end, let maxValueInt = maxValue else { + return nil + } + + if startInt <= endInt { + return Array(startInt...endInt) + } else { + return Array(startInt...maxValueInt) + Array(startValue...endInt) + } +} + +func daysInMonth(month: Int?, year: Int?) -> Int? { + let dateComponents = DateComponents(year: year, month: month) + let calendar = Calendar.current + guard let date = calendar.date(from: dateComponents), + let range = calendar.range(of: .day, in: .month, for: date) else { + return nil // Provide a default value in case of nil + } + return range.count +} + +func calculateDayMinute(hour: Int?, minute: Int?) -> Int? { + guard let hour = hour, let minute = minute else { + return nil + } + return hour * 60 + minute +} + +func calculate15MinBucket(hour: Int?, minute: Int?) -> Int? { + guard let hour = hour, let minute = minute else { + return nil + } + return hour * 4 + minute / 15 +}