diff --git a/NeutroFeverGuard.xcodeproj/project.pbxproj b/NeutroFeverGuard.xcodeproj/project.pbxproj index 5a9a2e0..97ea48c 100644 --- a/NeutroFeverGuard.xcodeproj/project.pbxproj +++ b/NeutroFeverGuard.xcodeproj/project.pbxproj @@ -107,8 +107,6 @@ EB5D7CF92D8108BC0046BF8E /* AccountButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFE8A82ABE551400428242 /* AccountButton.swift */; }; EB6FDBCE2D80133A008E9F90 /* HKVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6FDBCB2D80133A008E9F90 /* HKVisualization.swift */; }; EB6FDBCF2D80133A008E9F90 /* HKVisualizationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6FDBCC2D80133A008E9F90 /* HKVisualizationItem.swift */; }; - EB6FDBD02D80133A008E9F90 /* HKVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6FDBCB2D80133A008E9F90 /* HKVisualization.swift */; }; - EB6FDBD12D80133A008E9F90 /* HKVisualizationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6FDBCC2D80133A008E9F90 /* HKVisualizationItem.swift */; }; EBFA80652D81FBE600596C3C /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC3E29EDD7ED004B9AB4 /* FeatureFlags.swift */; }; F223937A2D5D4095006C8EB4 /* DataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22393792D5D4092006C8EB4 /* DataError.swift */; }; F268C4562D7F928C0020026F /* SymptomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F268C4552D7F928B0020026F /* SymptomManager.swift */; }; @@ -718,8 +716,6 @@ EB02C6612D5D52E90035AA89 /* NeutroFeverGuardTests.swift in Sources */, B0DF8DFD2D7C123200581A00 /* HealthDataFetchable.swift in Sources */, B0DF8DF72D7BFEFB00581A00 /* FeverMonitorTests.swift in Sources */, - EB6FDBD02D80133A008E9F90 /* HKVisualization.swift in Sources */, - EB6FDBD12D80133A008E9F90 /* HKVisualizationItem.swift in Sources */, B03D0E482D7D24AA0024F2CA /* DataError.swift in Sources */, EBFA80652D81FBE600596C3C /* FeatureFlags.swift in Sources */, C4C8A3B32D71204200F313AE /* HelperFuncTests.swift in Sources */, diff --git a/NeutroFeverGuard/DataType.swift b/NeutroFeverGuard/DataType.swift index 24872f9..e5c28f0 100644 --- a/NeutroFeverGuard/DataType.swift +++ b/NeutroFeverGuard/DataType.swift @@ -132,7 +132,7 @@ struct BloodPressureEntry { - Lab values: include the number associated with the lab name above */ -struct LabEntry: Codable { +struct LabEntry: Codable, Equatable { var date: Date var values: [LabTestType: Double] diff --git a/NeutroFeverGuard/HKVisualization.swift b/NeutroFeverGuard/HKVisualization.swift index 62bb972..9a5a6ba 100644 --- a/NeutroFeverGuard/HKVisualization.swift +++ b/NeutroFeverGuard/HKVisualization.swift @@ -1,5 +1,5 @@ -// periphery:ignore all // swiftlint:disable all +// periphery:ignore all // This source file is part of the NeutroFeverGuard based on the Stanford Spezi Template Application project // // SPDX-FileCopyrightText: 2025 Stanford University @@ -12,7 +12,16 @@ import HealthKit import SpeziAccount import SwiftUI -// Parses the raw HealthKit data. + +struct HKData: Identifiable, Codable { + var id = UUID() + var date: Date + var sumValue: Double + var avgValue: Double + var minValue: Double + var maxValue: Double +} + func parseSampleQueryData(results: [HKSample], quantityTypeIDF: HKQuantityTypeIdentifier) -> [HKData] { // Retrieve quantity value and time for each data point. @@ -79,23 +88,16 @@ func handleAuthorizationError(_ error: Error) -> String { } } -struct HKData: Identifiable { - var date: Date - var id = UUID() - var sumValue: Double - var avgValue: Double - var minValue: Double - var maxValue: Double -} - struct HKVisualization: View { - // swiftlint:disable closure_body_length + @Environment(LabResultsManager.self) private var labResultsManager @State var bodyTemperatureData: [HKData] = [] @State var heartRateData: [HKData] = [] @State var oxygenSaturationData: [HKData] = [] @State var heartRateScatterData: [HKData] = [] @State var oxygenSaturationScatterData: [HKData] = [] @State var bodyTemperatureScatterData: [HKData] = [] + @State var neutrophilData: [HKData] = [] + @State var neutrophilScatterData: [HKData] = [] var vizList: some View { self.readAllHKData() @@ -106,7 +108,7 @@ struct HKVisualization: View { data: heartRateData, xName: "Time", yName: "Heart Rate (bpm)", - title: "Heart Rate Over Time", + title: "Heart Rate", threshold: 100, scatterData: heartRateScatterData ) @@ -121,7 +123,7 @@ struct HKVisualization: View { data: bodyTemperatureData, xName: "Time", yName: "Body Temperature (°F)", - title: "Body Temperature Over Time", + title: "Body Temperature", threshold: 99.0, scatterData: bodyTemperatureScatterData ) @@ -136,7 +138,7 @@ struct HKVisualization: View { data: oxygenSaturationData, xName: "Time", yName: "Oxygen Saturation (%)", - title: "Oxygen Saturation Over Time", + title: "Oxygen Saturation", threshold: 94.0, scatterData: oxygenSaturationScatterData ) @@ -145,6 +147,21 @@ struct HKVisualization: View { .foregroundColor(.gray) } } + Section { + if !neutrophilData.isEmpty { + HKVisualizationItem( + data: neutrophilData, + xName: "Date", + yName: "Neutrophil Count", + title: "Absolute Neutrophil Count", + threshold: 500, // Adjust the threshold if necessary + scatterData: neutrophilScatterData + ) + } else { + Text("No neutrophil count data available.") + .foregroundColor(.gray) + } + } } } @@ -164,6 +181,8 @@ struct HKVisualization: View { .onAppear { // Ensure that data up-to-date when the view is activated. self.readAllHKData(ensureUpdate: true) + labResultsManager.loadLabResults() + loadNeutrophilData() // Ensure this is called } .toolbar { if account != nil { @@ -173,6 +192,41 @@ struct HKVisualization: View { } } + private func loadNeutrophilData() { + if FeatureFlags.mockVizData { + loadMockDataNew() + return + } + let rawData = labResultsManager.getAllAncValues().filter { + $0.date >= Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() + } + + // Convert to HKData for bar plot + neutrophilData = rawData.map { record in + HKData( + date: record.date, + sumValue: record.ancValue, + avgValue: record.ancValue, + minValue: record.ancValue, + maxValue: record.ancValue + ) + } + + // Create scatter data (with some random variation to separate points) + neutrophilScatterData = rawData.map { record in + HKData( + date: record.date, + sumValue: record.ancValue + Double.random(in: -0.5...0.5), // Add slight variation for visualization + avgValue: -1.0, + minValue: -1.0, + maxValue: -1.0 + ) + } + + print("✅ Converted neutrophil data: \(neutrophilData)") + print("✅ Scatter neutrophil data: \(neutrophilScatterData)") + } + func readAllHKData(ensureUpdate: Bool = false) { if FeatureFlags.mockVizData { loadMockDataNew() @@ -331,7 +385,8 @@ struct HKVisualization: View { self.heartRateData = minMaxAvgStatData self.bodyTemperatureData = minMaxAvgStatData self.oxygenSaturationData = minMaxAvgStatData - + self.neutrophilData = [HKData(date: today, sumValue: 0, avgValue: 500, minValue: 1, maxValue: 1000)] + // ✅ Heart Rate Scatter Data (60-100 bpm normal range) self.heartRateScatterData = [ HKData(date: today, sumValue: 75, avgValue: 75, minValue: 75, maxValue: 75), @@ -352,8 +407,24 @@ struct HKVisualization: View { HKData(date: yesterday, sumValue: 97, avgValue: 97, minValue: 97, maxValue: 97), HKData(date: twoDaysAgo, sumValue: 96, avgValue: 96, minValue: 96, maxValue: 96) ] + + let mockNeutrophilData = [ + (date: today, wbc: 1000, neutrophils: 50), + (date: yesterday, wbc: 1000, neutrophils: 5), + (date: twoDaysAgo, wbc: 1200, neutrophils: 20) + ] + + self.neutrophilScatterData = mockNeutrophilData.map { record in + let ancValue = (Double(record.neutrophils) / 100.0) * Double(record.wbc) + return HKData( + date: record.date, + sumValue: ancValue, + avgValue: ancValue, + minValue: ancValue, + maxValue: ancValue + ) + } } - // swiftlint:enable closure_body_length } func parseStat(statistics: HKStatistics, quantityTypeIDF: HKQuantityTypeIdentifier) -> HKData? { @@ -392,7 +463,3 @@ func parseValue(quantity: HKQuantity, quantityTypeIDF: HKQuantityTypeIdentifier) return -1.0 } } - -#Preview { - -} diff --git a/NeutroFeverGuard/HKVisualizationItem.swift b/NeutroFeverGuard/HKVisualizationItem.swift index e9cd618..31e1f95 100644 --- a/NeutroFeverGuard/HKVisualizationItem.swift +++ b/NeutroFeverGuard/HKVisualizationItem.swift @@ -1,5 +1,3 @@ -// swiftlint:disable all -// periphery:ignore all // This source file is part of the NeutroFeverGuard based on the Stanford Spezi Template Application project // // SPDX-FileCopyrightText: 2025 Stanford University @@ -12,8 +10,6 @@ import Foundation import SwiftUI struct HKVisualizationItem: View { - // periphery:ignore - let id = UUID() let data: [HKData] let xName: LocalizedStringResource let yName: LocalizedStringResource diff --git a/NeutroFeverGuard/LabResultsManager.swift b/NeutroFeverGuard/LabResultsManager.swift index f0e1b46..7e59057 100644 --- a/NeutroFeverGuard/LabResultsManager.swift +++ b/NeutroFeverGuard/LabResultsManager.swift @@ -42,7 +42,7 @@ class LabResultsManager: Module, EnvironmentAccessible { loadLabResults() // Refresh lab results } - private func loadLabResults() { + func loadLabResults() { var results: [LabEntry] = [] do { @@ -120,6 +120,20 @@ class LabResultsManager: Module, EnvironmentAccessible { } return (neutrophils / 100.0) * wbc } + + func getAllAncValues() -> [(date: Date, ancValue: Double)] { + var ancValues: [(date: Date, ancValue: Double)] = [] + + for record in labRecords { + if let neutrophils = record.values[.neutrophils], + let wbc = record.values[.whiteBloodCell] { + let ancValue = (neutrophils / 100.0) * wbc + ancValues.append((date: record.date, ancValue: ancValue)) + } + } + + return ancValues + } func getANCStatus() -> (text: String, color: Color) { guard let ancValue = getAncValue() else { diff --git a/NeutroFeverGuard/Resources/Localizable.xcstrings b/NeutroFeverGuard/Resources/Localizable.xcstrings index 333092e..0ba08c7 100644 --- a/NeutroFeverGuard/Resources/Localizable.xcstrings +++ b/NeutroFeverGuard/Resources/Localizable.xcstrings @@ -59,6 +59,9 @@ }, "1-10" : { + }, + "Absolute Neutrophil Count" : { + }, "Absolute Neutrophil Counts" : { "localizations" : { @@ -154,6 +157,9 @@ }, "Bluetooth Devices" : { + }, + "Body Temperature" : { + }, "Body Temperature (°F)" : { "localizations" : { @@ -166,6 +172,7 @@ } }, "Body Temperature Over Time" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -371,6 +378,9 @@ } } } + }, + "Heart Rate" : { + }, "Heart Rate (bpm)" : { "localizations" : { @@ -383,6 +393,7 @@ } }, "Heart Rate Over Time" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -639,6 +650,9 @@ } } } + }, + "Neutrophil Count" : { + }, "Next" : { "localizations" : { @@ -691,6 +705,9 @@ }, "No medications recorded" : { + }, + "No neutrophil count data available." : { + }, "No oxygen saturation data available." : { "localizations" : { @@ -748,6 +765,9 @@ }, "Other factors" : { + }, + "Oxygen Saturation" : { + }, "Oxygen Saturation (%)" : { "localizations" : { @@ -760,6 +780,7 @@ } }, "Oxygen Saturation Over Time" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/NeutroFeverGuardTests/NeutroFeverGuardTests.swift b/NeutroFeverGuardTests/NeutroFeverGuardTests.swift index 578fe74..d48ca2c 100644 --- a/NeutroFeverGuardTests/NeutroFeverGuardTests.swift +++ b/NeutroFeverGuardTests/NeutroFeverGuardTests.swift @@ -8,11 +8,11 @@ import Foundation import HealthKit +@testable import NeutroFeverGuard import Testing @MainActor struct NeutroFeverGuardTests { - @MainActor @Test("HK Data Initialization Test") func testHKDataInitialization() async throws { let testDate = Date() @@ -31,12 +31,8 @@ struct NeutroFeverGuardTests { #expect(hkData.maxValue == 90.0, "Maximum value should be 90.0") } - @MainActor @Test("Test Parse Value") func testParseValue() async throws { - let healthStore = HKHealthStore() - - // Heart Rate parsing let heartRateQuantity = HKQuantity(unit: HKUnit(from: "count/min"), doubleValue: 70) let heartRateValue = parseValue(quantity: heartRateQuantity, quantityTypeIDF: .heartRate) #expect(heartRateValue == 70.0, "Heart rate value should be 70.0") @@ -57,7 +53,6 @@ struct NeutroFeverGuardTests { #expect(defaultSatValue == -1.0, "Default value should be -1.0") } - @MainActor @Test func testparseSampleQueryData() async throws { let sampleDate = Date() @@ -70,7 +65,6 @@ struct NeutroFeverGuardTests { #expect(results.first?.sumValue == 95.0) } - @MainActor @Test func testGenerateDateRange() async throws { let range = generateDateRange() @@ -81,7 +75,6 @@ struct NeutroFeverGuardTests { #expect(range[2] is NSPredicate) } - @MainActor @Test func testHandleAuthorizationError() async throws { let error = HKError(.errorAuthorizationDenied) @@ -103,7 +96,6 @@ struct NeutroFeverGuardTests { #expect(unknownMessage == "Unknown error during HealthKit authorization: The operation couldn’t be completed. (TestError error 999.)") } - @MainActor @Test("Test HK Visualization Display") func testHKVisualizationDisplay() async throws { let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() @@ -124,7 +116,6 @@ struct NeutroFeverGuardTests { #expect(view != nil, "View should be initialized successfully") } - @MainActor @Test("Test HK Visualization Thereshold") func testHKVisualizationItemThreshold() async throws { let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() diff --git a/NeutroFeverGuardUITests/HKVisualizationHKTests.swift b/NeutroFeverGuardUITests/HKVisualizationHKTests.swift index 36cbd45..d6b3d4a 100644 --- a/NeutroFeverGuardUITests/HKVisualizationHKTests.swift +++ b/NeutroFeverGuardUITests/HKVisualizationHKTests.swift @@ -32,5 +32,6 @@ class HKVisualizationHKTests: XCTestCase { XCTAssertTrue(app.staticTexts["No heart rate data available."].waitForExistence(timeout: 2)) XCTAssertTrue(app.staticTexts["No body temperature data available."].waitForExistence(timeout: 2)) XCTAssertTrue(app.staticTexts["No oxygen saturation data available."].waitForExistence(timeout: 2)) + XCTAssertTrue(app.staticTexts["No neutrophil count data available."].waitForExistence(timeout: 2)) } } diff --git a/NeutroFeverGuardUITests/HKVisualizationTests.swift b/NeutroFeverGuardUITests/HKVisualizationTests.swift index 154220f..2e885f6 100644 --- a/NeutroFeverGuardUITests/HKVisualizationTests.swift +++ b/NeutroFeverGuardUITests/HKVisualizationTests.swift @@ -16,7 +16,7 @@ class HKVisualizationTests: XCTestCase { let app = XCUIApplication() app.launchArguments = ["--skipOnboarding", "--mockVizData"] - app.deleteAndLaunch(withSpringboardAppName: "NeutroFeverGuard") + app.launch() } @MainActor @@ -41,12 +41,12 @@ class HKVisualizationTests: XCTestCase { try app.handleHealthKitAuthorization() // Verify Plot Titles Exists - XCTAssertTrue(app.staticTexts["Oxygen Saturation Over Time"].exists) - XCTAssertTrue(app.staticTexts["Body Temperature Over Time"].exists) - XCTAssertTrue(app.staticTexts["Heart Rate Over Time"].exists) + XCTAssertTrue(app.staticTexts["Oxygen Saturation"].exists) + XCTAssertTrue(app.staticTexts["Body Temperature"].exists) + XCTAssertTrue(app.staticTexts["Heart Rate"].exists) // Wait for chart to appear - let chartTitle = app.staticTexts["Oxygen Saturation Over Time"] + let chartTitle = app.staticTexts["Oxygen Saturation"] XCTAssertTrue(chartTitle.waitForExistence(timeout: 5)) // Get the frame of the chart title @@ -93,7 +93,7 @@ class HKVisualizationTests: XCTestCase { try app.handleHealthKitAuthorization() // Check for Heart Rate Chart - let heartRateChartTitle = app.staticTexts["Heart Rate Over Time"] + let heartRateChartTitle = app.staticTexts["Heart Rate"] XCTAssertTrue(heartRateChartTitle.waitForExistence(timeout: 5), "Heart Rate chart title should exist") let heartRateThreshold = app.otherElements["Threshold"] @@ -111,7 +111,7 @@ class HKVisualizationTests: XCTestCase { try app.handleHealthKitAuthorization() // Wait for chart to appear - let chartTitle = app.staticTexts["Body Temperature Over Time"] + let chartTitle = app.staticTexts["Body Temperature"] XCTAssertTrue(chartTitle.waitForExistence(timeout: 5)) // Get the frame of the chart title @@ -158,7 +158,7 @@ class HKVisualizationTests: XCTestCase { try app.handleHealthKitAuthorization() // Wait for chart to appear - let chartTitle = app.staticTexts["Heart Rate Over Time"] + let chartTitle = app.staticTexts["Heart Rate"] XCTAssertTrue(chartTitle.waitForExistence(timeout: 5)) // Get the frame of the chart title @@ -205,24 +205,93 @@ class HKVisualizationTests: XCTestCase { try app.handleHealthKitAuthorization() // Check for Heart Rate Chart - let heartRateChartTitle = app.staticTexts["Heart Rate Over Time"] + let heartRateChartTitle = app.staticTexts["Heart Rate"] XCTAssertTrue(heartRateChartTitle.waitForExistence(timeout: 5), "Heart Rate chart title should exist") let heartRateThreshold = app.otherElements["Threshold"] XCTAssertTrue(heartRateThreshold.waitForExistence(timeout: 2), "Heart Rate threshold line should be visible.") // Check for Body Temperature Chart - let bodyTempChartTitle = app.staticTexts["Body Temperature Over Time"] + let bodyTempChartTitle = app.staticTexts["Body Temperature"] XCTAssertTrue(bodyTempChartTitle.waitForExistence(timeout: 5), "Body Temperature chart title should exist") let bodyTempThreshold = app.otherElements["Threshold"] XCTAssertTrue(bodyTempThreshold.waitForExistence(timeout: 2), "Body Temperature threshold line should be visible.") // Check for Oxygen Saturation Chart - let oxygenSatChartTitle = app.staticTexts["Oxygen Saturation Over Time"] + let oxygenSatChartTitle = app.staticTexts["Oxygen Saturation"] XCTAssertTrue(oxygenSatChartTitle.waitForExistence(timeout: 5), "Oxygen Saturation chart title should exist") let oxygenSatThreshold = app.otherElements["Threshold"] XCTAssertTrue(oxygenSatThreshold.waitForExistence(timeout: 2), "Oxygen Saturation threshold line should be visible.") } + + @MainActor + func testHealthDashboardNeutrophils() throws { + let app = XCUIApplication() + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + + XCTAssertTrue(app.tabBars["Tab Bar"].buttons["Dashboard"].waitForExistence(timeout: 2)) + app.tabBars["Tab Bar"].buttons["Dashboard"].tap() + + var maxScrollAttempts = 3 + while maxScrollAttempts > 0 { + print("📍 Swiping up to find Neutrophil chart...") + app.swipeUp() + sleep(1) // Allow UI time to update + maxScrollAttempts -= 1 + } + + let chartTitle = app.staticTexts["Absolute Neutrophil Count"] + XCTAssertTrue(chartTitle.waitForExistence(timeout: 5), "Neutrophil chart should be visible after scrolling.") + + // ✅ Tap on the chart to bring up the summary view + let frame = chartTitle.frame + let tapPoint = CGPoint(x: frame.maxX - 50, y: frame.maxY + 100) + app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: tapPoint.x, dy: tapPoint.y)).tap() + + // ✅ Verify summary date (should be today's date) + let today = Date() + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + let dateStr = formatter.string(from: today) + + let summaryDate = app.staticTexts["Summary_Date"] + XCTAssertTrue(summaryDate.exists) + XCTAssertEqual(summaryDate.label, "Summary: \(dateStr)") + + // ✅ Check the summary ANC values + let summaryAverage = app.staticTexts["Summary_Average"] + XCTAssertTrue(summaryAverage.exists, "Average ANC value should exist") + XCTAssertEqual(summaryAverage.label, "Average: 500.0") // ✅ Adjusted to mock value + + let summaryMax = app.staticTexts["Summary_Max"] + XCTAssertTrue(summaryMax.exists, "Max ANC value should exist") + XCTAssertEqual(summaryMax.label, "Max value: 1000") // ✅ Adjusted to mock value + + let summaryMin = app.staticTexts["Summary_Min"] + XCTAssertTrue(summaryMin.exists, "Min ANC value should exist") + XCTAssertEqual(summaryMin.label, "Min value: 1") // ✅ Adjusted to mock value + } + + @MainActor + func testThresholdNeutrophils() throws { + let app = XCUIApplication() + + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) + + XCTAssertTrue(app.tabBars["Tab Bar"].buttons["Dashboard"].waitForExistence(timeout: 2)) + app.tabBars["Tab Bar"].buttons["Dashboard"].tap() + try app.handleHealthKitAuthorization() + + // ✅ Check if "Neutrophil Count Over Past Week" chart exists + let chartTitle = app.staticTexts["Absolute Neutrophil Count"] + XCTAssertTrue(chartTitle.waitForExistence(timeout: 5), "Neutrophil chart title should exist") + + // ✅ Verify threshold line exists (should be around **500 ANC**) + let neutrophilThreshold = app.otherElements["Threshold"] + XCTAssertTrue(neutrophilThreshold.waitForExistence(timeout: 2), "Neutrophil threshold line should be visible.") + } }