diff --git a/packages/react-native-healthkit/src/healthkit.ios.ts b/packages/react-native-healthkit/src/healthkit.ios.ts index 99fb8f3..0c55291 100644 --- a/packages/react-native-healthkit/src/healthkit.ios.ts +++ b/packages/react-native-healthkit/src/healthkit.ios.ts @@ -19,6 +19,7 @@ import { Workouts, } from './modules' import type { QuantityTypeIdentifier } from './types/QuantityTypeIdentifier' +import generateQuantityTypeSamples from './utils/generateQuantityTypeSamples' import getMostRecentCategorySample from './utils/getMostRecentCategorySample' import getMostRecentQuantitySample from './utils/getMostRecentQuantitySample' import getMostRecentWorkout from './utils/getMostRecentWorkout' @@ -49,6 +50,7 @@ export type AvailableQuantityTypesBeforeIOS17 = Exclude< > export { + generateQuantityTypeSamples, getMostRecentCategorySample, getMostRecentQuantitySample, getMostRecentWorkout, @@ -222,6 +224,7 @@ export default { isProtectedDataAvailable, queryStateOfMindSamples, saveStateOfMindSample, + generateQuantityTypeSamples, // hooks useMostRecentCategorySample, diff --git a/packages/react-native-healthkit/src/healthkit.ts b/packages/react-native-healthkit/src/healthkit.ts index 8439a46..7422fc1 100644 --- a/packages/react-native-healthkit/src/healthkit.ts +++ b/packages/react-native-healthkit/src/healthkit.ts @@ -302,6 +302,11 @@ export const getPreferredUnit = UnavailableFnFromModule( Promise.resolve('count'), ) // Defaulting to 'count' +export const generateQuantityTypeSamples = UnavailableFnFromModule( + 'generateQuantityTypeSamples', + Promise.resolve(false), +) + // Hooks (from original export list) export function useMostRecentCategorySample( _categoryTypeIdentifier: T, @@ -420,6 +425,7 @@ const HealthkitModule = { isProtectedDataAvailable, queryStateOfMindSamples, saveStateOfMindSample, + generateQuantityTypeSamples, // Hooks useMostRecentCategorySample, diff --git a/packages/react-native-healthkit/src/utils/generateQuantityTypeSamples.test.ts b/packages/react-native-healthkit/src/utils/generateQuantityTypeSamples.test.ts new file mode 100644 index 0000000..409ef98 --- /dev/null +++ b/packages/react-native-healthkit/src/utils/generateQuantityTypeSamples.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from 'bun:test' + +import { QuantityTypes } from '../modules' +import generateQuantityTypeSamples from './generateQuantityTypeSamples' + +describe('generateQuantityTypeSamples', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('should generate samples with default date range', async () => { + const saveMock = jest + .spyOn(QuantityTypes, 'saveQuantitySample') + .mockResolvedValue(true) + const queryMock = jest + .spyOn(QuantityTypes, 'queryQuantitySamples') + .mockResolvedValue([]) + + const result = await generateQuantityTypeSamples( + 'HKQuantityTypeIdentifierStepCount', + 5, + ) + + expect(result).toBe(true) + expect(saveMock).toHaveBeenCalledTimes(5) + expect(queryMock).toHaveBeenCalledTimes(1) + }) + + test('should generate samples with custom date range', async () => { + const saveMock = jest + .spyOn(QuantityTypes, 'saveQuantitySample') + .mockResolvedValue(true) + const queryMock = jest + .spyOn(QuantityTypes, 'queryQuantitySamples') + .mockResolvedValue([]) + + const fromDate = new Date('2024-01-01') + const toDate = new Date('2024-01-07') + + const result = await generateQuantityTypeSamples( + 'HKQuantityTypeIdentifierHeartRate', + 7, + { fromDate, toDate }, + ) + + expect(result).toBe(true) + expect(saveMock).toHaveBeenCalledTimes(7) + }) + + test('should return false if any sample fails to save', async () => { + jest + .spyOn(QuantityTypes, 'saveQuantitySample') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + jest.spyOn(QuantityTypes, 'queryQuantitySamples').mockResolvedValue([]) + + const result = await generateQuantityTypeSamples( + 'HKQuantityTypeIdentifierStepCount', + 3, + ) + + expect(result).toBe(false) + }) + + test('should spread samples evenly across time range', async () => { + const saveMock = jest + .spyOn(QuantityTypes, 'saveQuantitySample') + .mockResolvedValue(true) + jest.spyOn(QuantityTypes, 'queryQuantitySamples').mockResolvedValue([]) + + const fromDate = new Date('2024-01-01T00:00:00Z') + const toDate = new Date('2024-01-04T00:00:00Z') + + await generateQuantityTypeSamples('HKQuantityTypeIdentifierStepCount', 3, { + fromDate, + toDate, + }) + + const calls = saveMock.mock.calls + expect(calls.length).toBe(3) + + // Check that dates are spread evenly (each day) + const dates = calls.map((call) => call[3] as Date) + const timeRange = toDate.getTime() - fromDate.getTime() + const interval = timeRange / 3 + + for (let i = 0; i < dates.length; i++) { + const expectedTime = fromDate.getTime() + interval * i + expect(dates[i]?.getTime()).toBe(expectedTime) + } + }) + + test('should use realistic values from the predefined ranges', async () => { + const saveMock = jest + .spyOn(QuantityTypes, 'saveQuantitySample') + .mockResolvedValue(true) + jest.spyOn(QuantityTypes, 'queryQuantitySamples').mockResolvedValue([]) + + await generateQuantityTypeSamples('HKQuantityTypeIdentifierHeartRate', 10) + + const calls = saveMock.mock.calls + const values = calls.map((call) => call[2] as number) + + // Heart rate should be between 60 and 100 based on our realistic ranges + for (const value of values) { + expect(value).toBeGreaterThanOrEqual(60) + expect(value).toBeLessThanOrEqual(100) + } + }) + + test('should handle errors gracefully', async () => { + jest + .spyOn(QuantityTypes, 'saveQuantitySample') + .mockRejectedValue(new Error('Save failed')) + jest.spyOn(QuantityTypes, 'queryQuantitySamples').mockResolvedValue([]) + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const result = await generateQuantityTypeSamples( + 'HKQuantityTypeIdentifierStepCount', + 2, + ) + + expect(result).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalledTimes(2) + + consoleErrorSpy.mockRestore() + }) +}) diff --git a/packages/react-native-healthkit/src/utils/generateQuantityTypeSamples.ts b/packages/react-native-healthkit/src/utils/generateQuantityTypeSamples.ts new file mode 100644 index 0000000..4c54913 --- /dev/null +++ b/packages/react-native-healthkit/src/utils/generateQuantityTypeSamples.ts @@ -0,0 +1,229 @@ +import { QuantityTypes } from '../modules' +import type { QuantityTypeIdentifier } from '../types/QuantityTypeIdentifier' + +/** + * Options for generating quantity samples + */ +export interface GenerateQuantitySamplesOptions { + /** + * Start date for the generated samples. Defaults to 30 days ago. + */ + fromDate?: Date + /** + * End date for the generated samples. Defaults to now. + */ + toDate?: Date +} + +/** + * Realistic value ranges for different quantity types + * Maps quantity type identifiers to [min, max] value ranges + */ +const REALISTIC_RANGES: Partial< + Record +> = { + // Body Measurements + HKQuantityTypeIdentifierHeight: [150, 200], // cm + HKQuantityTypeIdentifierBodyMass: [50, 120], // kg + HKQuantityTypeIdentifierBodyMassIndex: [18, 35], + HKQuantityTypeIdentifierBodyFatPercentage: [10, 40], // % + HKQuantityTypeIdentifierLeanBodyMass: [40, 80], // kg + HKQuantityTypeIdentifierWaistCircumference: [60, 120], // cm + + // Activity + HKQuantityTypeIdentifierStepCount: [1000, 15000], + HKQuantityTypeIdentifierDistanceWalkingRunning: [1000, 10000], // meters + HKQuantityTypeIdentifierDistanceCycling: [2000, 30000], // meters + HKQuantityTypeIdentifierDistanceWheelchair: [500, 5000], // meters + HKQuantityTypeIdentifierBasalEnergyBurned: [1200, 2000], // kcal + HKQuantityTypeIdentifierActiveEnergyBurned: [200, 1000], // kcal + HKQuantityTypeIdentifierFlightsClimbed: [5, 50], + HKQuantityTypeIdentifierAppleExerciseTime: [10, 120], // minutes + HKQuantityTypeIdentifierPushCount: [20, 200], + HKQuantityTypeIdentifierDistanceSwimming: [500, 3000], // meters + HKQuantityTypeIdentifierSwimmingStrokeCount: [100, 2000], + HKQuantityTypeIdentifierVO2Max: [20, 60], // ml/kg/min + HKQuantityTypeIdentifierDistanceDownhillSnowSports: [1000, 20000], // meters + HKQuantityTypeIdentifierAppleStandTime: [8, 16], // hours + + // Vitals + HKQuantityTypeIdentifierHeartRate: [60, 100], // bpm + HKQuantityTypeIdentifierBodyTemperature: [36.1, 37.2], // celsius + HKQuantityTypeIdentifierBasalBodyTemperature: [36.1, 37.2], // celsius + HKQuantityTypeIdentifierBloodPressureSystolic: [110, 140], // mmHg + HKQuantityTypeIdentifierBloodPressureDiastolic: [70, 90], // mmHg + HKQuantityTypeIdentifierRespiratoryRate: [12, 20], // breaths/min + HKQuantityTypeIdentifierRestingHeartRate: [50, 80], // bpm + HKQuantityTypeIdentifierWalkingHeartRateAverage: [80, 120], // bpm + HKQuantityTypeIdentifierHeartRateVariabilitySDNN: [20, 100], // ms + HKQuantityTypeIdentifierOxygenSaturation: [95, 100], // % + HKQuantityTypeIdentifierBloodGlucose: [70, 180], // mg/dL + + // Nutrition + HKQuantityTypeIdentifierDietaryFatTotal: [20, 100], // g + HKQuantityTypeIdentifierDietaryFatPolyunsaturated: [5, 30], // g + HKQuantityTypeIdentifierDietaryFatMonounsaturated: [10, 50], // g + HKQuantityTypeIdentifierDietaryFatSaturated: [5, 30], // g + HKQuantityTypeIdentifierDietaryCholesterol: [100, 400], // mg + HKQuantityTypeIdentifierDietarySodium: [1500, 3500], // mg + HKQuantityTypeIdentifierDietaryCarbohydrates: [100, 400], // g + HKQuantityTypeIdentifierDietaryFiber: [15, 50], // g + HKQuantityTypeIdentifierDietarySugar: [20, 100], // g + HKQuantityTypeIdentifierDietaryEnergyConsumed: [1500, 3000], // kcal + HKQuantityTypeIdentifierDietaryProtein: [50, 200], // g + HKQuantityTypeIdentifierDietaryVitaminA: [500, 3000], // IU + HKQuantityTypeIdentifierDietaryVitaminB6: [1, 5], // mg + HKQuantityTypeIdentifierDietaryVitaminB12: [2, 10], // mcg + HKQuantityTypeIdentifierDietaryVitaminC: [50, 200], // mg + HKQuantityTypeIdentifierDietaryVitaminD: [400, 2000], // IU + HKQuantityTypeIdentifierDietaryVitaminE: [10, 50], // mg + HKQuantityTypeIdentifierDietaryVitaminK: [60, 200], // mcg + HKQuantityTypeIdentifierDietaryCalcium: [800, 1500], // mg + HKQuantityTypeIdentifierDietaryIron: [8, 30], // mg + HKQuantityTypeIdentifierDietaryThiamin: [1, 3], // mg + HKQuantityTypeIdentifierDietaryRiboflavin: [1, 3], // mg + HKQuantityTypeIdentifierDietaryNiacin: [10, 30], // mg + HKQuantityTypeIdentifierDietaryFolate: [200, 600], // mcg + HKQuantityTypeIdentifierDietaryBiotin: [20, 100], // mcg + HKQuantityTypeIdentifierDietaryPantothenicAcid: [3, 10], // mg + HKQuantityTypeIdentifierDietaryPhosphorus: [500, 1500], // mg + HKQuantityTypeIdentifierDietaryIodine: [100, 300], // mcg + HKQuantityTypeIdentifierDietaryMagnesium: [200, 500], // mg + HKQuantityTypeIdentifierDietaryZinc: [8, 20], // mg + HKQuantityTypeIdentifierDietarySelenium: [50, 200], // mcg + HKQuantityTypeIdentifierDietaryCopper: [0.5, 3], // mg + HKQuantityTypeIdentifierDietaryManganese: [1, 5], // mg + HKQuantityTypeIdentifierDietaryChromium: [20, 100], // mcg + HKQuantityTypeIdentifierDietaryMolybdenum: [30, 100], // mcg + HKQuantityTypeIdentifierDietaryChloride: [1500, 3500], // mg + HKQuantityTypeIdentifierDietaryPotassium: [2000, 4500], // mg + HKQuantityTypeIdentifierDietaryCaffeine: [50, 400], // mg + HKQuantityTypeIdentifierDietaryWater: [1500, 3500], // ml + + // Other + HKQuantityTypeIdentifierNumberOfTimesFallen: [0, 5], + HKQuantityTypeIdentifierElectrodermalActivity: [0.1, 20], // microsiemens + HKQuantityTypeIdentifierInhalerUsage: [1, 8], + HKQuantityTypeIdentifierInsulinDelivery: [10, 80], // units + HKQuantityTypeIdentifierBloodAlcoholContent: [0, 0.08], // % + HKQuantityTypeIdentifierForcedVitalCapacity: [2, 6], // L + HKQuantityTypeIdentifierForcedExpiratoryVolume1: [1.5, 5], // L + HKQuantityTypeIdentifierPeakExpiratoryFlowRate: [300, 700], // L/min + HKQuantityTypeIdentifierEnvironmentalAudioExposure: [30, 90], // dB + HKQuantityTypeIdentifierHeadphoneAudioExposure: [40, 100], // dB + HKQuantityTypeIdentifierNumberOfAlcoholicBeverages: [1, 5], +} + +/** + * Default range for quantity types not in the map + */ +const DEFAULT_RANGE: [number, number] = [0, 100] + +/** + * Generate a random value within a range + */ +function randomInRange(min: number, max: number): number { + return min + Math.random() * (max - min) +} + +/** + * Generate test quantity samples for simulator testing. + * This function creates realistic quantity samples spread evenly over a time period. + * + * @param quantityType - The quantity type identifier to generate samples for + * @param numberOfSamples - Number of samples to generate + * @param options - Optional configuration for date range + * @returns Promise that resolves to true if all samples were saved successfully + * + * @example + * ```typescript + * // Generate 30 step count samples over the last 30 days + * await generateQuantityTypeSamples('HKQuantityTypeIdentifierStepCount', 30) + * + * // Generate 7 heart rate samples over a custom date range + * await generateQuantityTypeSamples( + * 'HKQuantityTypeIdentifierHeartRate', + * 7, + * { + * fromDate: new Date('2024-01-01'), + * toDate: new Date('2024-01-07') + * } + * ) + * ``` + */ +export async function generateQuantityTypeSamples( + quantityType: QuantityTypeIdentifier, + numberOfSamples: number, + options?: GenerateQuantitySamplesOptions, +): Promise { + const now = new Date() + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + + const fromDate = options?.fromDate ?? thirtyDaysAgo + const toDate = options?.toDate ?? now + + const timeRange = toDate.getTime() - fromDate.getTime() + const [minValue, maxValue] = REALISTIC_RANGES[quantityType] ?? DEFAULT_RANGE + + // Get the preferred unit for this quantity type + const preferredUnits = await QuantityTypes.queryQuantitySamples( + quantityType, + { + limit: 1, + }, + ) + + let unit = 'count' // default unit + if (preferredUnits.length > 0 && preferredUnits[0]) { + unit = preferredUnits[0].unit + } else { + // Try to determine unit from the quantity type + // Most activity metrics use 'count' or distance uses 'm' + if (quantityType.includes('Distance')) { + unit = 'm' + } else if (quantityType.includes('Energy')) { + unit = 'kcal' + } else if (quantityType.includes('HeartRate')) { + unit = 'count/min' + } else if (quantityType.includes('Temperature')) { + unit = 'degC' + } else if (quantityType.includes('BloodPressure')) { + unit = 'mmHg' + } else if (quantityType.includes('OxygenSaturation')) { + unit = '%' + } + } + + const results: boolean[] = [] + + for (let i = 0; i < numberOfSamples; i++) { + // Spread samples evenly across the time range + const sampleTime = fromDate.getTime() + (timeRange / numberOfSamples) * i + const startDate = new Date(sampleTime) + + // End date is the same as start date for most quantity samples + // (they represent a point-in-time measurement) + const endDate = new Date(sampleTime) + + const value = randomInRange(minValue, maxValue) + + try { + const result = await QuantityTypes.saveQuantitySample( + quantityType, + unit, + value, + startDate, + endDate, + {}, // empty metadata + ) + results.push(result) + } catch (error) { + console.error(`Failed to save sample for ${quantityType}:`, error) + results.push(false) + } + } + + return results.every((result) => result === true) +} + +export default generateQuantityTypeSamples