diff --git a/Prisma.xcodeproj/project.pbxproj b/Prisma.xcodeproj/project.pbxproj index 51d2a37..a340005 100644 --- a/Prisma.xcodeproj/project.pbxproj +++ b/Prisma.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ D8027E912B90655700BB9466 /* ManageDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8027E902B90655700BB9466 /* ManageDataView.swift */; }; D8F136C52B85CEED000BA7AE /* DeleteDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F136C42B85CEED000BA7AE /* DeleteDataView.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 */; }; @@ -161,6 +162,7 @@ D8027E902B90655700BB9466 /* ManageDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageDataView.swift; sourceTree = ""; }; D8F136C42B85CEED000BA7AE /* DeleteDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDataView.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 = ""; }; @@ -450,6 +452,7 @@ F8AF6FB82B5F72650011C32D /* PrismaStandard+HealthKit.swift */, F8AF6FB32B5F6EDC0011C32D /* PrismaModule.swift */, F8AF6FB52B5F71460011C32D /* PrismaStandard+Extension.swift */, + F83B7CBD2B8FFE6800662914 /* PrismaStandard+TimeIndex.swift */, F8AF6FBB2B5F74EA0011C32D /* PrismaStandard+Questionnaire.swift */, ); path = Standard; @@ -689,6 +692,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 11bcc46..c1c9b0c 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 { @@ -46,7 +27,7 @@ extension PrismaStandard { return nil } } - + /// Takes in HKSampleType and returns the corresponding identifier string /// /// - Parameters: @@ -71,41 +52,15 @@ extension PrismaStandard { func add(sample: HKSample) async { let identifier: String if let id = getSampleIdentifier(sample: sample) { - print("Sample identifier: \(id)") identifier = id } else { - print("Unknown sample type") + print("Failed to upload HealtHkit sample. Unknown sample type: \(sample)") return } -// var sampleToToggleNameMapping: [HKQuantityType?: String] = [ -// HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned): "includeActiveEnergyBurned", -// HKQuantityType.quantityType(forIdentifier: .stepCount): "includeStepCountUpload", -// HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning): "includeDistanceWalkingRunning", -// HKQuantityType.quantityType(forIdentifier: .vo2Max): "includeVo2Max", -// HKQuantityType.quantityType(forIdentifier: .heartRate): "includeHeartRate", -// HKQuantityType.quantityType(forIdentifier: .restingHeartRate): "includeRestingHeartRate", -// HKQuantityType.quantityType(forIdentifier: .oxygenSaturation): "includeOxygenSaturation", -// HKQuantityType.quantityType(forIdentifier: .respiratoryRate): "includeRespiratoryRate", -// HKQuantityType.quantityType(forIdentifier: .walkingHeartRateAverage): "includeWalkingHeartRateAverage" -// ] -// var toggleNameToBoolMapping: [String: Bool] = PrivacyModule().getCurrentToggles() -// -// if let variableName = sampleToToggleNameMapping[quantityType] { -// let response: Bool = toggleNameToBoolMapping[variableName] ?? false -// -// if !response { -// return -// } -// } else { -// return -// } - - // convert the startDate of the HKSample to local time - let startDatetime = sample.startDate - let effectiveTimestamp = startDatetime.toISOFormat() -// let endDatetime = sample.endDate.toISOFormat() + let timeIndex = constructTimeIndex(startDate: sample.startDate, endDate: sample.endDate) + let effectiveTimestamp = sample.startDate.toISOFormat() let path: String // path = HEALTH_KIT_PATH/raw/YYYY-MM-DDThh:mm:ss.mss @@ -131,6 +86,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..49ac85a --- /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.toISOFormat(), + "datetime.end": endDate.toISOFormat() + ] + + 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 +}