From 857c30b82dfad601b6e1d0962b261c2f79f04c93 Mon Sep 17 00:00:00 2001 From: chehanw Date: Thu, 29 Jan 2026 01:20:01 -0800 Subject: [PATCH 01/11] Fix bugs in chatbot and fixed enrollment flow (medical records after consent form) --- .DS_Store | Bin 6148 -> 6148 bytes homeflow/app/(onboarding)/_layout.tsx | 6 + homeflow/app/(onboarding)/chat.tsx | 116 +----- homeflow/app/(onboarding)/index.tsx | 3 + homeflow/app/(onboarding)/medical-history.tsx | 347 ++++++++++++++++++ homeflow/app/(onboarding)/permissions.tsx | 8 +- homeflow/lib/constants.ts | 4 +- .../__tests__/onboarding-service.test.ts | 6 +- .../packages/chat/src/components/ChatView.tsx | 106 +++--- homeflow/packages/chat/src/services/llm.ts | 6 + 10 files changed, 446 insertions(+), 156 deletions(-) create mode 100644 homeflow/app/(onboarding)/medical-history.tsx diff --git a/.DS_Store b/.DS_Store index 382132b13182cb1c358b87cbf0706a49a5c18950..f52b672570c9bf6ee44b902e00c1575533a076e0 100644 GIT binary patch delta 777 zcmY+CO-vI(9L48t`Pjm0r&#fm6l_F;1Z#o`n21G;fXjJvW{8TO2#TIeJehEj#Kfov4<1R3H>cZjaFU&u-+$i!?aXB6!}H;Ll}b_B z!qv)!h8lPldRL!RD;{_jHT=$+B9n@ncAOApIi51PaKhnDD4wF?l2X-MR<7*`UTE*Q zsCC77!nXSO92=uPF=@|qNbF6s3}-Wg3EP}bT4Id)6S|q0Hg$)!nr6-{Yd(=k+osuT z3+`~+8j3k=*4Ylq{G@=FP(T+}Zp&J8;VHhJA z1;Ye5u3!odGML3R%;5%Z;udb>4({Ut9^w&}@EA|<6lb1c887e>uds?Wyu&));{!fn z17GnSKk|E3kXO=O(1KuV@h74yrK(Sr4}8V1{P8UM0?ky~-3~b^IqOO+@Z9qcPZh>v delta 80 zcmZoMXfc=|#>B`mu~2NHo+2aD#DLwC4MbQb^Rv91e2m?3vjPV@%f^P|jGNgx_&I>; dHVblmXP(S2Vky7?1dI#}Oi-F-bA-qmW&mA|5*h#i diff --git a/homeflow/app/(onboarding)/_layout.tsx b/homeflow/app/(onboarding)/_layout.tsx index f9f3983..3cb1a2f 100644 --- a/homeflow/app/(onboarding)/_layout.tsx +++ b/homeflow/app/(onboarding)/_layout.tsx @@ -61,6 +61,12 @@ export default function OnboardingLayout() { animation: 'slide_from_right', }} /> + {getPhaseText()} diff --git a/homeflow/app/(onboarding)/index.tsx b/homeflow/app/(onboarding)/index.tsx index ec63231..7cb576e 100644 --- a/homeflow/app/(onboarding)/index.tsx +++ b/homeflow/app/(onboarding)/index.tsx @@ -60,6 +60,9 @@ export default function OnboardingRouter() { case OnboardingStep.PERMISSIONS: return ; + case OnboardingStep.MEDICAL_HISTORY: + return ; + case OnboardingStep.BASELINE_SURVEY: return ; diff --git a/homeflow/app/(onboarding)/medical-history.tsx b/homeflow/app/(onboarding)/medical-history.tsx new file mode 100644 index 0000000..94eceda --- /dev/null +++ b/homeflow/app/(onboarding)/medical-history.tsx @@ -0,0 +1,347 @@ +/** + * Medical History Chat Screen + * + * Collects medical history through natural conversation with AI assistant. + * This screen appears after consent and permissions (Apple Health / Throne). + * + * ============================================================================= + * FUTURE APPLE HEALTH INTEGRATION NOTES: + * ============================================================================= + * + * This chatbot currently serves as the ONLY avenue for medical history + * collection since we are not yet pulling health records from Apple Health. + * + * When Apple Health Clinical Records access is available, the intended flow is: + * 1. Pull available health records from Apple Health (medications, labs, + * conditions, procedures) via connected health systems (MyChart, Epic, etc.) + * 2. Determine which required fields are still missing after the pull + * 3. Use THIS chatbot to ask the user ONLY about the gaps + * 4. If Apple Health provides ALL required fields, the chatbot will simply + * display: "We got everything we need from your health records. + * You're all set!" and show the Continue button immediately. + * + * The chatbot acts as a fail-safe to ensure we always collect complete + * medical history, regardless of what Apple Health can provide. + * + * ============================================================================= + */ + +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + useColorScheme, + Animated, + Keyboard, + TouchableWithoutFeedback, +} from 'react-native'; +import { useRouter, Href } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Constants from 'expo-constants'; +import { ChatView, ChatProvider } from '@spezivibe/chat'; +import { Colors, StanfordColors, Spacing } from '@/constants/theme'; +import { OnboardingStep, STUDY_INFO } from '@/lib/constants'; +import { OnboardingService } from '@/lib/services/onboarding-service'; +import { OnboardingProgressBar, ContinueButton, DevToolBar } from '@/components/onboarding'; +import { IconSymbol } from '@/components/ui/icon-symbol'; + +/** + * System prompt for medical history collection. + * + * NOTE: Currently this chatbot asks about ALL medical history fields because + * we are not yet pulling records from Apple Health. When health records + * integration is implemented, this prompt should be dynamically modified to + * only ask about fields NOT already filled by health records. + */ +const SYSTEM_PROMPT = `You are a friendly research assistant collecting medical history for the HomeFlow BPH study. The participant has already been confirmed eligible and has given informed consent. Now you need to collect their medical history. + +## Study Information +- Name: ${STUDY_INFO.name} +- Institution: ${STUDY_INFO.institution} +- Purpose: Track voiding patterns and symptoms before/after bladder outlet surgery + +## Context +The participant has already: +- Passed eligibility screening (has iPhone, has BPH/LUTS, planning bladder outlet surgery) +- Signed informed consent +- Granted permissions for Apple Health and Throne uroflow + +## What to Collect + +### Data We Get Automatically from Apple Health (DO NOT ASK): +- Age / Date of Birth +- Biological Sex +- Height +- Weight / BMI + +### Data You MUST Collect (not available from Apple Health): + +#### 1. Demographics +- Full name (for study records) +- Ethnicity: Hispanic/Latino or Not Hispanic/Latino +- Race + +#### 2. BPH/LUTS Medications (BE THOROUGH - ask about each category) +Go through each medication class: +1. Alpha blockers: "Are you taking tamsulosin (Flomax), alfuzosin (Uroxatral), silodosin (Rapaflo), doxazosin, or terazosin?" +2. 5-alpha reductase inhibitors: "Are you taking finasteride (Proscar) or dutasteride (Avodart)?" +3. Anticholinergics: "Are you taking oxybutynin (Ditropan), tolterodine (Detrol), solifenacin (Vesicare), or trospium (Sanctura)?" +4. Beta-3 agonists: "Are you taking mirabegron (Myrbetriq) or vibegron (Gemtesa)?" +5. Any other bladder or prostate medications + +#### 3. Surgical History +- Prior BPH/prostate surgeries: Ask about TURP, HoLEP, GreenLight, UroLift, Rezum, Aquablation, or any other prostate procedures. Get type AND approximate date. +- General surgical history: Any other past surgeries (type and approximate year) + +#### 4. Lab Values (ask if they know these) +- PSA (Prostate Specific Antigen): Most recent value and when it was done. Explain: "This is a blood test often done for prostate screening." +- Urinalysis: Any recent urine test results, especially if anything abnormal was found + +#### 5. Key Medical Conditions (CRITICAL - must ask about these specifically) +- **Diabetes**: Ask directly! If yes, ask about HbA1c level (explain: "This is a blood sugar control number, usually between 5-10%") +- **Hypertension**: High blood pressure - are they diagnosed? Is it controlled with medication? +- Other significant conditions + +#### 6. Clinical Measurements (if they've had these tests) +- PVR (Post-Void Residual) or bladder scan: "Have you had a bladder scan after urinating? If so, what was the residual volume in mL?" +- Clinic uroflow: "Have you done a urine flow test at your doctor's office? If so, what was your Qmax (maximum flow rate)?" +- Mobility status: How active are they? Any limitations? + +#### 7. Upcoming Surgery +- Date of scheduled BPH surgery (if known) +- Type of surgery planned (TURP, HoLEP, UroLift, Rezum, etc.) + +## Conversation Guidelines +- Be warm, conversational, and empathetic +- Ask 2-3 related items at a time, don't overwhelm +- Group questions logically (all medications together, then conditions, etc.) +- Acknowledge symptoms supportively when mentioned +- If they don't know a value (like PSA or HbA1c), that's OK - just note "unknown" and continue +- NEVER give medical advice or interpret their values + +## Important Response Markers (include these exact phrases) +When ALL medical history sections are complete: [HISTORY_COMPLETE] + +## Conversation Flow +1. Start with a brief introduction: "Now let's collect some medical history. We'll automatically get things like your age and weight from Apple Health, but I need to ask you about medications, conditions, and a few other things." +2. Work through sections in order: Demographics → Medications → Surgeries → Labs → Conditions → Clinical data → Planned surgery +3. Before finishing, summarize: "Let me confirm what I have..." then list key points +4. End with: "I have everything I need. [HISTORY_COMPLETE] You can tap Continue to proceed." + +## Start the Conversation +"Thanks for completing the consent process! Now I need to collect some medical history. We'll pull basic info like your age and weight from Apple Health, so I just need to ask about a few other things. + +Let's start with some basic demographics - could you tell me your full name?"`; + +type MedicalHistoryPhase = 'collecting' | 'complete'; + +export default function MedicalHistoryScreen() { + const router = useRouter(); + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + + const [phase, setPhase] = useState('collecting'); + const [canContinue, setCanContinue] = useState(false); + + // Animation for continue button + const buttonOpacity = useRef(new Animated.Value(0)).current; + + // Get API key from environment + const apiKey = Constants.expoConfig?.extra?.openaiApiKey || process.env.EXPO_PUBLIC_OPENAI_API_KEY || ''; + + // Chat provider config + const provider: ChatProvider = useMemo( + () => ({ + type: 'openai', + apiKey, + model: 'gpt-4o-mini', + }), + [apiKey] + ); + + useEffect(() => { + if (canContinue) { + Animated.spring(buttonOpacity, { + toValue: 1, + useNativeDriver: true, + tension: 50, + friction: 8, + }).start(); + } + }, [canContinue, buttonOpacity]); + + // Watch for completion marker in chat messages + const checkForMarkers = useCallback((message: string) => { + const lowerMessage = message.toLowerCase(); + + if (message.includes('[HISTORY_COMPLETE]') || (lowerMessage.includes("all set") && lowerMessage.includes("continue"))) { + setPhase('complete'); + setCanContinue(true); + } + }, []); + + const handleContinue = async () => { + // Save collected data (in a real app, you'd parse the chat transcript) + await OnboardingService.updateData({ + medicalHistory: { + medications: [], + conditions: [], + allergies: [], + surgicalHistory: [], + bphTreatmentHistory: [], + rawTranscript: 'collected via chatbot', + }, + }); + + await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY); + router.push('/(onboarding)/baseline-survey' as Href); + }; + + const getPhaseText = () => { + switch (phase) { + case 'collecting': + return 'Collecting medical history...'; + case 'complete': + return 'Ready to continue!'; + default: + return ''; + } + }; + + // If no API key, show a placeholder + if (!apiKey) { + return ( + + + + + + + + Medical History Chat Not Available + + + OpenAI API key not configured. For demo purposes, tap Continue to proceed. + + + + + + + ); + } + + return ( + + + + + + + {getPhaseText()} + + + + + + + Starting conversation... + + + } + /> + + {canContinue && ( + + + Medical history collected. Ready for the next step. + + + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + paddingTop: Spacing.sm, + }, + phaseIndicator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: Spacing.sm, + }, + phaseDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8, + }, + phaseText: { + fontSize: 13, + fontWeight: '500', + }, + emptyState: { + alignItems: 'center', + gap: Spacing.sm, + }, + emptyStateText: { + fontSize: 15, + }, + continueContainer: { + padding: Spacing.md, + paddingBottom: Spacing.lg, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(0,0,0,0.1)', + gap: Spacing.sm, + }, + continueHint: { + fontSize: 14, + textAlign: 'center', + }, + noApiKey: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: Spacing.screenHorizontal, + }, + noApiKeyTitle: { + fontSize: 20, + fontWeight: '600', + marginTop: Spacing.md, + marginBottom: Spacing.sm, + }, + noApiKeyText: { + fontSize: 15, + textAlign: 'center', + lineHeight: 22, + }, +}); diff --git a/homeflow/app/(onboarding)/permissions.tsx b/homeflow/app/(onboarding)/permissions.tsx index eb5b7b8..eb1d0c6 100644 --- a/homeflow/app/(onboarding)/permissions.tsx +++ b/homeflow/app/(onboarding)/permissions.tsx @@ -144,8 +144,8 @@ export default function PermissionsScreen() { }, }); - await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY); - router.push('/(onboarding)/baseline-survey' as Href); + await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); + router.push('/(onboarding)/medical-history' as Href); } finally { setIsLoading(false); } @@ -153,8 +153,8 @@ export default function PermissionsScreen() { // Dev-only handler that bypasses permission requirements const handleDevContinue = async () => { - await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY); - router.push('/(onboarding)/baseline-survey' as Href); + await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); + router.push('/(onboarding)/medical-history' as Href); }; return ( diff --git a/homeflow/lib/constants.ts b/homeflow/lib/constants.ts index 11ea8dc..0206671 100644 --- a/homeflow/lib/constants.ts +++ b/homeflow/lib/constants.ts @@ -40,9 +40,10 @@ export const CONSENT_KEY = '@consent_given'; */ export enum OnboardingStep { WELCOME = 'welcome', - CHAT = 'chat', // Combined eligibility + medical history + CHAT = 'chat', // Eligibility screening CONSENT = 'consent', PERMISSIONS = 'permissions', + MEDICAL_HISTORY = 'medical_history', // Medical history collection (chatbot) BASELINE_SURVEY = 'baseline_survey', COMPLETE = 'complete', } @@ -55,6 +56,7 @@ export const ONBOARDING_FLOW: OnboardingStep[] = [ OnboardingStep.CHAT, OnboardingStep.CONSENT, OnboardingStep.PERMISSIONS, + OnboardingStep.MEDICAL_HISTORY, OnboardingStep.BASELINE_SURVEY, OnboardingStep.COMPLETE, ]; diff --git a/homeflow/lib/services/__tests__/onboarding-service.test.ts b/homeflow/lib/services/__tests__/onboarding-service.test.ts index 7a66941..1338410 100644 --- a/homeflow/lib/services/__tests__/onboarding-service.test.ts +++ b/homeflow/lib/services/__tests__/onboarding-service.test.ts @@ -5,7 +5,7 @@ * enrollment flow for the research study. It tracks which step the user * is on, stores collected data, and persists state so users can resume. * - * Onboarding steps: WELCOME → CHAT → CONSENT → PERMISSIONS → BASELINE_SURVEY → COMPLETE + * Onboarding steps: WELCOME → CHAT → CONSENT → PERMISSIONS → MEDICAL_HISTORY → BASELINE_SURVEY → COMPLETE * * Key behaviors tested: * - State machine navigation (start, nextStep, goToStep, complete) @@ -541,8 +541,8 @@ describe('OnboardingService', () => { await service.initialize(); const progress = service.getProgress(); - // CONSENT is index 2 in a 6-step flow (indices 0-5) - // Progress = (2 / 5) * 100 = 40% + // CONSENT is index 2 in a 7-step flow (indices 0-6) + // Progress = (2 / 6) * 100 = 33% const expectedIndex = ONBOARDING_FLOW.indexOf(OnboardingStep.CONSENT); const expectedProgress = Math.round((expectedIndex / (ONBOARDING_FLOW.length - 1)) * 100); expect(progress).toBe(expectedProgress); diff --git a/homeflow/packages/chat/src/components/ChatView.tsx b/homeflow/packages/chat/src/components/ChatView.tsx index 892b68a..97a1748 100644 --- a/homeflow/packages/chat/src/components/ChatView.tsx +++ b/homeflow/packages/chat/src/components/ChatView.tsx @@ -43,6 +43,10 @@ export function ChatView({ const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); + // Ref keeps latest messages for use in callbacks without stale closures + const messagesRef = useRef(messages); + messagesRef.current = messages; + // Auto-scroll to bottom when new messages arrive useEffect(() => { if (messages.length > 0) { @@ -72,59 +76,58 @@ export function ChatView({ setInput(''); setIsLoading(true); - // Build messages for LLM - const llmMessages: LLMMessage[] = []; - if (systemPrompt) { - llmMessages.push({ role: 'system', content: systemPrompt }); - } - // Add previous messages - messages.forEach((msg) => { - if (msg.role !== 'system') { - llmMessages.push({ role: msg.role, content: msg.content }); + try { + // Build messages for LLM (read from ref to avoid stale closure) + const llmMessages: LLMMessage[] = []; + if (systemPrompt) { + llmMessages.push({ role: 'system', content: systemPrompt }); } - }); - llmMessages.push({ role: 'user', content: userMessage.content }); - - abortControllerRef.current = new AbortController(); - - // Track accumulated content for the callback - let fullContent = ''; - - await streamChatCompletion( - llmMessages, - provider, - { - onToken: (token) => { - fullContent += token; - setMessages((prev) => - prev.map((msg) => - msg.id === assistantMessage.id - ? { ...msg, content: msg.content + token } - : msg - ) - ); - }, - onComplete: () => { - setIsLoading(false); - abortControllerRef.current = null; - onResponse?.(fullContent); + messagesRef.current.forEach((msg) => { + if (msg.role !== 'system') { + llmMessages.push({ role: msg.role, content: msg.content }); + } + }); + llmMessages.push({ role: 'user', content: userMessage.content }); + + abortControllerRef.current = new AbortController(); + + // Track accumulated content for the callback + let fullContent = ''; + + await streamChatCompletion( + llmMessages, + provider, + { + onToken: (token) => { + fullContent += token; + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessage.id + ? { ...msg, content: msg.content + token } + : msg + ) + ); + }, + onComplete: () => { + onResponse?.(fullContent); + }, + onError: (error) => { + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessage.id + ? { ...msg, content: `Error: ${error.message}` } + : msg + ) + ); + }, }, - onError: (error) => { - setIsLoading(false); - abortControllerRef.current = null; - // Update the assistant message with error - setMessages((prev) => - prev.map((msg) => - msg.id === assistantMessage.id - ? { ...msg, content: `Error: ${error.message}` } - : msg - ) - ); - }, - }, - abortControllerRef.current.signal - ); - }, [input, isLoading, messages, provider, systemPrompt]); + abortControllerRef.current.signal + ); + } finally { + setIsLoading(false); + abortControllerRef.current = null; + } + }, [input, isLoading, provider, systemPrompt, onResponse]); const handleStop = useCallback(() => { abortControllerRef.current?.abort(); @@ -153,6 +156,7 @@ export function ChatView({ item.id} renderItem={renderItem} contentContainerStyle={[ diff --git a/homeflow/packages/chat/src/services/llm.ts b/homeflow/packages/chat/src/services/llm.ts index 4c00c97..6447f54 100644 --- a/homeflow/packages/chat/src/services/llm.ts +++ b/homeflow/packages/chat/src/services/llm.ts @@ -69,8 +69,14 @@ export async function streamChatCompletion( abortSignal, }); + let tokenCount = 0; for await (const chunk of result.textStream) { callbacks.onToken(chunk); + // Yield to the UI thread periodically so React can paint between tokens. + // Prevents stalls when expo/fetch delivers chunks in bursts. + if (++tokenCount % 8 === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } } callbacks.onComplete(); } catch (error) { From d0ca9e8a419011335cd479709d5a4a2b81429940 Mon Sep 17 00:00:00 2001 From: chehanw Date: Sun, 1 Feb 2026 15:02:09 -0800 Subject: [PATCH 02/11] Fix missing MEDICAL_HISTORY route in DevToolBar STEP_TO_PATH map The MEDICAL_HISTORY step was added to OnboardingStep enum and ONBOARDING_FLOW but never added to the DevToolBar path map, causing a crash when navigating back from BASELINE_SURVEY. Co-Authored-By: Claude Opus 4.5 --- homeflow/components/onboarding/DevToolBar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/homeflow/components/onboarding/DevToolBar.tsx b/homeflow/components/onboarding/DevToolBar.tsx index 227bb0e..b3b9488 100644 --- a/homeflow/components/onboarding/DevToolBar.tsx +++ b/homeflow/components/onboarding/DevToolBar.tsx @@ -18,6 +18,7 @@ const STEP_TO_PATH: Record = { [OnboardingStep.CHAT]: '/(onboarding)/chat', [OnboardingStep.CONSENT]: '/(onboarding)/consent', [OnboardingStep.PERMISSIONS]: '/(onboarding)/permissions', + [OnboardingStep.MEDICAL_HISTORY]: '/(onboarding)/medical-history', [OnboardingStep.BASELINE_SURVEY]: '/(onboarding)/baseline-survey', [OnboardingStep.COMPLETE]: '/(onboarding)/complete', }; From 631a9a4b4cd90e6a6eaed0bb8e8f8814af0a09e5 Mon Sep 17 00:00:00 2001 From: chehanw Date: Wed, 4 Feb 2026 01:02:53 -0800 Subject: [PATCH 03/11] [Large] Implement comprehensive HealthKit integration for activity, sleep, and vitals This is a major feature commit that adds full HealthKit data querying capabilities for the HomeFlow BPH study, including daily activity tracking, sleep stage analysis, and Apple Watch vitals collection. New Features: - Daily activity queries: steps, exercise/move/stand minutes, sedentary time estimation - Sleep queries with iOS 16+ stage support (Awake/Core/Deep/REM) and legacy fallback - Vitals queries: heart rate stats, resting HR, HRV (SDNN), respiratory rate - Date range utilities for bucketing health samples by day - Debug panel UI for testing 7/30 day data fetches - Detailed permission status tracking New Types: - DailyActivityDay, SleepNight, SleepStage enum, VitalsDay - PermissionResult with granted/denied/notDetermined arrays - Query result types (DailyActivityResult, SleepResult, VitalsResult) API Additions: - getDailyActivity(range), getSleep(range), getVitals(range) - getDateRange(days), date bucketing utilities - HealthKitService.querySleepSamples() for category type queries - HealthKitService.requestAuthorizationWithStatus() for detailed permissions Testing: - 75 tests covering date utilities, sleep stage mapping, sedentary calculation Note: Sedentary time is approximated as (16h waking - exercise - stand) since HealthKit doesn't track sedentary time directly. This is documented as a limitation. Co-Authored-By: Claude Opus 4.5 --- homeflow/app/(tabs)/health.tsx | 476 +++++++++++++++++- homeflow/lib/healthkit-config.ts | 62 ++- .../src/__tests__/activity-queries.test.ts | 82 +++ .../src/__tests__/date-utils.test.ts | 228 +++++++++ .../src/__tests__/sleep-queries.test.ts | 298 +++++++++++ homeflow/packages/healthkit/src/index.ts | 79 ++- .../src/services/HealthKitService.ts | 167 ++++++ .../packages/healthkit/src/services/index.ts | 25 +- .../src/services/queries/activity.ts | 180 +++++++ .../healthkit/src/services/queries/index.ts | 33 ++ .../healthkit/src/services/queries/sleep.ts | 268 ++++++++++ .../healthkit/src/services/queries/vitals.ts | 257 ++++++++++ homeflow/packages/healthkit/src/types.ts | 223 ++++++++ homeflow/packages/healthkit/src/utils/date.ts | 172 +++++++ .../packages/healthkit/src/utils/index.ts | 15 + 15 files changed, 2554 insertions(+), 11 deletions(-) create mode 100644 homeflow/packages/healthkit/src/__tests__/activity-queries.test.ts create mode 100644 homeflow/packages/healthkit/src/__tests__/date-utils.test.ts create mode 100644 homeflow/packages/healthkit/src/__tests__/sleep-queries.test.ts create mode 100644 homeflow/packages/healthkit/src/services/queries/activity.ts create mode 100644 homeflow/packages/healthkit/src/services/queries/index.ts create mode 100644 homeflow/packages/healthkit/src/services/queries/sleep.ts create mode 100644 homeflow/packages/healthkit/src/services/queries/vitals.ts create mode 100644 homeflow/packages/healthkit/src/utils/date.ts diff --git a/homeflow/app/(tabs)/health.tsx b/homeflow/app/(tabs)/health.tsx index 0bec541..97b68a3 100644 --- a/homeflow/app/(tabs)/health.tsx +++ b/homeflow/app/(tabs)/health.tsx @@ -1,5 +1,14 @@ -import React from 'react'; -import { StyleSheet, useColorScheme, Platform } from 'react-native'; +import React, { useState, useCallback } from 'react'; +import { + StyleSheet, + useColorScheme, + Platform, + View, + Text, + TouchableOpacity, + ScrollView, + ActivityIndicator, +} from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { HealthKitProvider, @@ -7,13 +16,302 @@ import { ExpoGoFallback, defaultLightHealthTheme, defaultDarkHealthTheme, + HealthKitService, + getDateRange, + getDailyActivity, + getSleep, + getVitals, + type DailyActivityResult, + type SleepResult, + type VitalsResult, + type PermissionResult, } from '@spezivibe/healthkit'; -import { healthKitConfig } from '@/lib/healthkit-config'; +import { healthKitConfig, ALL_HEALTH_TYPES } from '@/lib/healthkit-config'; + +/** + * Debug panel for testing HealthKit integration + */ +function DebugPanel() { + const [isLoading, setIsLoading] = useState(false); + const [permissionResult, setPermissionResult] = useState(null); + const [activityData, setActivityData] = useState(null); + const [sleepData, setSleepData] = useState(null); + const [vitalsData, setVitalsData] = useState(null); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<'activity' | 'sleep' | 'vitals'>('activity'); + + const handleRequestPermissions = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const result = await HealthKitService.requestAuthorizationWithStatus( + [...healthKitConfig.collect, ...(healthKitConfig.readOnly ?? [])], + healthKitConfig.collect + ); + setPermissionResult(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Permission request failed'); + } finally { + setIsLoading(false); + } + }, []); + + const handleFetchData = useCallback(async (days: number) => { + setIsLoading(true); + setError(null); + try { + const range = getDateRange(days); + + // Fetch all data types in parallel + const [activity, sleep, vitals] = await Promise.all([ + getDailyActivity(range), + getSleep(range), + getVitals(range), + ]); + + setActivityData(activity); + setSleepData(sleep); + setVitalsData(vitals); + } catch (err) { + setError(err instanceof Error ? err.message : 'Data fetch failed'); + } finally { + setIsLoading(false); + } + }, []); + + const colorScheme = useColorScheme(); + const isDark = colorScheme === 'dark'; + + return ( + + + HealthKit Debug Panel + + + {/* Permission Section */} + + + + {isLoading ? 'Requesting...' : 'Request Permissions'} + + + + {permissionResult && ( + + + Status: {permissionResult.ok ? 'Success' : 'Failed'} + + {permissionResult.granted.length > 0 && ( + + Granted: {permissionResult.granted.length} types + + )} + {permissionResult.denied.length > 0 && ( + + Denied: {permissionResult.denied.length} types + + )} + + Note: Read permissions always show as undetermined for privacy + + + )} + + + {/* Fetch Data Section */} + + + handleFetchData(7)} + disabled={isLoading} + > + Fetch 7 Days + + handleFetchData(30)} + disabled={isLoading} + > + Fetch 30 Days + + + + + {isLoading && ( + + )} + + {error && ( + + {error} + + )} + + {/* Data Tabs */} + {(activityData || sleepData || vitalsData) && ( + + + {(['activity', 'sleep', 'vitals'] as const).map((tab) => ( + setActiveTab(tab)} + > + + {tab.charAt(0).toUpperCase() + tab.slice(1)} + + + ))} + + + + {activeTab === 'activity' && activityData && ( + + )} + {activeTab === 'sleep' && sleepData && ( + + )} + {activeTab === 'vitals' && vitalsData && ( + + )} + + + )} + + ); +} + +function ActivityDataView({ data, isDark }: { data: DailyActivityResult; isDark: boolean }) { + return ( + + {data.days.map((day) => ( + + {day.date} + + Steps: {day.steps.toLocaleString()} + + + Exercise: {day.exerciseMinutes} min + + + Move: {day.moveMinutes} min + + + Stand: {day.standMinutes} min + + + Sedentary: ~{day.sedentaryMinutes} min (est.) + + + Energy: {day.activeEnergyBurned} kcal + + + ))} + + ); +} + +function SleepDataView({ data, isDark }: { data: SleepResult; isDark: boolean }) { + return ( + + {data.nights.map((night) => ( + + + {night.date} (night) + + + Total Asleep: {Math.round(night.totalAsleepMinutes / 60 * 10) / 10}h + + + In Bed: {Math.round(night.totalInBedMinutes / 60 * 10) / 10}h + + + Efficiency: {night.sleepEfficiency}% + + {night.hasDetailedStages ? ( + <> + Stages: + + Awake: {night.stages.awake} min + + + Core: {night.stages.core} min + + + Deep: {night.stages.deep} min + + + REM: {night.stages.rem} min + + + ) : night.stages.asleepUndifferentiated > 0 ? ( + + (Legacy data - stages not available) + + ) : ( + + No sleep data recorded + + )} + + ))} + + ); +} + +function VitalsDataView({ data, isDark }: { data: VitalsResult; isDark: boolean }) { + return ( + + {data.days.map((day) => ( + + {day.date} + {day.heartRate.sampleCount > 0 ? ( + <> + + HR: {day.heartRate.min}-{day.heartRate.max} bpm (avg {day.heartRate.average}) + + + Samples: {day.heartRate.sampleCount} + + + ) : ( + + HR: No data + + )} + + Resting HR: {day.restingHeartRate ?? 'N/A'} bpm + + + HRV: {day.hrv ?? 'N/A'} ms + + + Resp Rate: {day.respiratoryRate ?? 'N/A'} br/min + + + SpO2: {day.oxygenSaturation ? `${day.oxygenSaturation}%` : 'N/A'} + + + ))} + + ); +} export default function HealthScreen() { const colorScheme = useColorScheme(); const theme = colorScheme === 'dark' ? defaultDarkHealthTheme : defaultLightHealthTheme; + const [showDebug, setShowDebug] = useState(false); // HealthKit is iOS only if (Platform.OS !== 'ios') { @@ -35,7 +333,21 @@ export default function HealthScreen() { config={healthKitConfig} expoGoFallback={} > - + + {/* Toggle Debug Panel */} + setShowDebug(!showDebug)} + > + + {showDebug ? 'Hide Debug Panel' : 'Show Debug Panel'} + + + + {showDebug && } + + + ); @@ -45,4 +357,160 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + scrollView: { + flex: 1, + }, + debugToggle: { + backgroundColor: '#007AFF', + padding: 12, + margin: 16, + borderRadius: 8, + alignItems: 'center', + }, + debugToggleText: { + color: '#fff', + fontWeight: '600', + fontSize: 15, + }, + debugPanel: { + margin: 16, + marginTop: 0, + }, + debugTitle: { + fontSize: 20, + fontWeight: '700', + marginBottom: 16, + color: '#000', + }, + textDark: { + color: '#fff', + }, + textMutedDark: { + color: '#aaa', + }, + section: { + marginBottom: 16, + }, + button: { + padding: 14, + borderRadius: 10, + alignItems: 'center', + }, + primaryButton: { + backgroundColor: '#34C759', + }, + secondaryButton: { + backgroundColor: '#f0f0f0', + flex: 1, + }, + buttonText: { + color: '#fff', + fontWeight: '600', + fontSize: 16, + }, + secondaryButtonText: { + color: '#007AFF', + fontWeight: '600', + fontSize: 15, + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + }, + resultBox: { + backgroundColor: '#f8f8f8', + padding: 12, + borderRadius: 8, + marginTop: 12, + }, + resultBoxDark: { + backgroundColor: '#1c1c1e', + }, + resultLabel: { + fontWeight: '600', + marginBottom: 4, + color: '#000', + }, + resultText: { + fontSize: 14, + marginBottom: 2, + }, + grantedText: { + color: '#34C759', + }, + deniedText: { + color: '#FF3B30', + }, + resultNote: { + fontSize: 12, + color: '#666', + marginTop: 8, + fontStyle: 'italic', + }, + errorBox: { + backgroundColor: '#FFE5E5', + padding: 12, + borderRadius: 8, + marginBottom: 16, + }, + errorText: { + color: '#FF3B30', + }, + loader: { + marginVertical: 16, + }, + tabBar: { + flexDirection: 'row', + marginBottom: 12, + backgroundColor: '#e0e0e0', + borderRadius: 8, + padding: 4, + }, + tab: { + flex: 1, + paddingVertical: 8, + alignItems: 'center', + borderRadius: 6, + }, + activeTab: { + backgroundColor: '#fff', + }, + tabText: { + color: '#666', + fontWeight: '500', + }, + activeTabText: { + color: '#007AFF', + fontWeight: '600', + }, + dataContainer: { + maxHeight: 400, + }, + dayCard: { + backgroundColor: '#f8f8f8', + padding: 12, + borderRadius: 8, + marginBottom: 8, + }, + dayCardDark: { + backgroundColor: '#1c1c1e', + }, + dateText: { + fontWeight: '600', + fontSize: 15, + marginBottom: 6, + color: '#000', + }, + dataText: { + fontSize: 13, + color: '#666', + marginBottom: 2, + }, + stageLabel: { + fontSize: 13, + fontWeight: '500', + marginTop: 6, + marginBottom: 2, + color: '#000', + }, }); diff --git a/homeflow/lib/healthkit-config.ts b/homeflow/lib/healthkit-config.ts index b96d92a..ed5c9ce 100644 --- a/homeflow/lib/healthkit-config.ts +++ b/homeflow/lib/healthkit-config.ts @@ -3,30 +3,88 @@ * * Configure which health data types to collect and display. * Modify this file to customize the health metrics for your app. + * + * HomeFlow BPH Study Requirements: + * - Activity: steps, exercise time, stand time (for sedentary estimation) + * - Sleep: sleep stages (iOS 16+) with fallback to basic sleep analysis + * - Vitals: heart rate, resting HR, HRV, respiratory rate */ import { HealthKitConfig, SampleType } from '@spezivibe/healthkit'; export const healthKitConfig: HealthKitConfig = { // Health data types to collect (request read/write access) + // These are the core metrics for the BPH study collect: [ + // Activity metrics SampleType.stepCount, - SampleType.heartRate, SampleType.activeEnergyBurned, + SampleType.appleExerciseTime, + SampleType.appleMoveTime, + SampleType.appleStandTime, + SampleType.distanceWalkingRunning, + + // Sleep SampleType.sleepAnalysis, + + // Vitals + SampleType.heartRate, ], // Health data types to read only (no write access) + // These are recorded by Apple Watch automatically readOnly: [ + // Body measurements (for context) SampleType.bodyMass, SampleType.height, + + // Advanced vitals (from Apple Watch) + SampleType.restingHeartRate, + SampleType.heartRateVariabilitySDNN, + SampleType.respiratoryRate, + SampleType.oxygenSaturation, + SampleType.walkingHeartRateAverage, ], // Enable background delivery for these types (optional) + // Uncomment to enable daily background sync // backgroundDelivery: [ // SampleType.stepCount, + // SampleType.sleepAnalysis, // ], - // Whether to sync health data to the backend (optional) + // Whether to sync health data to the backend + // Set to true when backend integration is ready syncToBackend: false, }; + +/** + * Activity types used for daily activity queries + */ +export const ACTIVITY_TYPES = [ + SampleType.stepCount, + SampleType.activeEnergyBurned, + SampleType.appleExerciseTime, + SampleType.appleMoveTime, + SampleType.appleStandTime, + SampleType.distanceWalkingRunning, +] as const; + +/** + * Vitals types used for daily vitals queries + */ +export const VITALS_TYPES = [ + SampleType.heartRate, + SampleType.restingHeartRate, + SampleType.heartRateVariabilitySDNN, + SampleType.respiratoryRate, + SampleType.oxygenSaturation, +] as const; + +/** + * All types that need to be requested for full functionality + */ +export const ALL_HEALTH_TYPES = [ + ...healthKitConfig.collect, + ...(healthKitConfig.readOnly ?? []), +] as const; diff --git a/homeflow/packages/healthkit/src/__tests__/activity-queries.test.ts b/homeflow/packages/healthkit/src/__tests__/activity-queries.test.ts new file mode 100644 index 0000000..ed8094d --- /dev/null +++ b/homeflow/packages/healthkit/src/__tests__/activity-queries.test.ts @@ -0,0 +1,82 @@ +/** + * Activity Query Tests + * + * Tests for activity aggregation and sedentary time calculation. + */ + +import { calculateSedentaryMinutes } from '../services/queries/activity'; + +describe('Activity Queries', () => { + describe('calculateSedentaryMinutes', () => { + // Assumed waking hours: 16 hours = 960 minutes + + it('returns full waking time when no activity', () => { + const sedentary = calculateSedentaryMinutes(0, 0); + expect(sedentary).toBe(960); // 16 hours + }); + + it('subtracts exercise minutes from waking time', () => { + const sedentary = calculateSedentaryMinutes(60, 0); // 1 hour exercise + expect(sedentary).toBe(900); // 15 hours + }); + + it('uses the higher value between exercise and stand time', () => { + // If exercise > stand, use exercise + const sedentary1 = calculateSedentaryMinutes(120, 60); + expect(sedentary1).toBe(840); // 16h - 2h = 14h + + // If stand > exercise, use stand + const sedentary2 = calculateSedentaryMinutes(30, 120); + expect(sedentary2).toBe(840); // 16h - 2h = 14h + }); + + it('returns zero when activity exceeds waking time', () => { + // This is an edge case that shouldn't happen in practice + const sedentary = calculateSedentaryMinutes(1000, 0); + expect(sedentary).toBe(0); + }); + + it('handles typical daily activity', () => { + // Typical Apple Watch user: 30 min exercise, 8 stand hours + // Stand time in minutes (already converted) + const sedentary = calculateSedentaryMinutes(30, 8 * 60); + expect(sedentary).toBe(960 - 480); // 16h - 8h stand = 8h + }); + + it('rounds to integer minutes', () => { + // Even with non-integer inputs, output should be rounded + const sedentary = calculateSedentaryMinutes(30, 45); + expect(Number.isInteger(sedentary)).toBe(true); + }); + }); + + describe('Sedentary Time Approximation Notes', () => { + /** + * IMPORTANT: This test documents the limitations of our sedentary time calculation. + * + * Limitations: + * 1. Apple HealthKit does NOT directly track sedentary time + * 2. We approximate sedentary = waking_hours - max(exercise, stand) + * 3. Stand time counts 1 minute per hour if you stood for 1+ min + * 4. This is a ROUGH estimate and may not reflect actual sitting time + * + * What this calculation captures: + * - General activity level (more exercise/standing = less sedentary) + * - Trend over time (are sedentary hours increasing or decreasing?) + * + * What this calculation DOES NOT capture: + * - Actual sitting time + * - Time spent in light activity vs sitting + * - Accurate breakdown of the day + * + * For research purposes: + * - Use this as a relative metric, not absolute + * - Compare trends within the same user + * - Consider combining with step count for better activity picture + */ + it('documents calculation limitations', () => { + // This test exists for documentation purposes + expect(true).toBe(true); + }); + }); +}); diff --git a/homeflow/packages/healthkit/src/__tests__/date-utils.test.ts b/homeflow/packages/healthkit/src/__tests__/date-utils.test.ts new file mode 100644 index 0000000..34cc1eb --- /dev/null +++ b/homeflow/packages/healthkit/src/__tests__/date-utils.test.ts @@ -0,0 +1,228 @@ +/** + * Date Utilities Tests + * + * Tests for date range bucketing, day boundary calculations, + * and other date utility functions. + */ + +import { + getDateRange, + getDayBoundaries, + formatDateKey, + parseDateKey, + getDateKeysInRange, + bucketByDay, + durationInMinutes, + isDateInRange, + isSameDay, +} from '../utils/date'; + +describe('Date Utilities', () => { + describe('formatDateKey', () => { + it('formats date as YYYY-MM-DD', () => { + const date = new Date(2024, 0, 15); // Jan 15, 2024 + expect(formatDateKey(date)).toBe('2024-01-15'); + }); + + it('pads single digit months and days', () => { + const date = new Date(2024, 8, 5); // Sep 5, 2024 + expect(formatDateKey(date)).toBe('2024-09-05'); + }); + + it('handles end of year', () => { + const date = new Date(2024, 11, 31); // Dec 31, 2024 + expect(formatDateKey(date)).toBe('2024-12-31'); + }); + }); + + describe('parseDateKey', () => { + it('parses YYYY-MM-DD to date at midnight', () => { + const date = parseDateKey('2024-01-15'); + expect(date.getFullYear()).toBe(2024); + expect(date.getMonth()).toBe(0); // January + expect(date.getDate()).toBe(15); + expect(date.getHours()).toBe(0); + expect(date.getMinutes()).toBe(0); + }); + + it('round-trips with formatDateKey', () => { + const original = '2024-06-20'; + const parsed = parseDateKey(original); + expect(formatDateKey(parsed)).toBe(original); + }); + }); + + describe('getDayBoundaries', () => { + it('returns start and end of day', () => { + const date = new Date(2024, 5, 15, 14, 30); // Jun 15, 2024, 2:30pm + const { start, end } = getDayBoundaries(date); + + expect(start.getHours()).toBe(0); + expect(start.getMinutes()).toBe(0); + expect(start.getSeconds()).toBe(0); + + expect(end.getHours()).toBe(23); + expect(end.getMinutes()).toBe(59); + expect(end.getSeconds()).toBe(59); + + // Same day + expect(formatDateKey(start)).toBe('2024-06-15'); + expect(formatDateKey(end)).toBe('2024-06-15'); + }); + }); + + describe('getDateRange', () => { + it('returns range for last N days', () => { + const { start, end } = getDateRange(7); + + // End should be now (or very close) + const now = new Date(); + expect(end.getTime()).toBeLessThanOrEqual(now.getTime()); + expect(end.getTime()).toBeGreaterThan(now.getTime() - 1000); + + // Start should be 6 days ago at midnight + const sixDaysAgo = new Date(); + sixDaysAgo.setDate(sixDaysAgo.getDate() - 6); + sixDaysAgo.setHours(0, 0, 0, 0); + expect(start.getTime()).toBe(sixDaysAgo.getTime()); + }); + + it('returns single day for days=1', () => { + const { start } = getDateRange(1); + const today = new Date(); + today.setHours(0, 0, 0, 0); + expect(start.getTime()).toBe(today.getTime()); + }); + }); + + describe('getDateKeysInRange', () => { + it('returns array of date keys for range', () => { + const start = new Date(2024, 0, 1); // Jan 1 + start.setHours(0, 0, 0, 0); + const end = new Date(2024, 0, 5); // Jan 5 + end.setHours(23, 59, 59, 999); + + const keys = getDateKeysInRange({ start, end }); + + expect(keys).toEqual([ + '2024-01-01', + '2024-01-02', + '2024-01-03', + '2024-01-04', + '2024-01-05', + ]); + }); + + it('returns single key for same-day range', () => { + const date = new Date(2024, 5, 15); + const { start, end } = getDayBoundaries(date); + const keys = getDateKeysInRange({ start, end }); + + expect(keys).toEqual(['2024-06-15']); + }); + + it('handles month boundaries', () => { + const start = new Date(2024, 0, 30); + start.setHours(0, 0, 0, 0); + const end = new Date(2024, 1, 2); + end.setHours(23, 59, 59, 999); + + const keys = getDateKeysInRange({ start, end }); + + expect(keys).toEqual([ + '2024-01-30', + '2024-01-31', + '2024-02-01', + '2024-02-02', + ]); + }); + }); + + describe('bucketByDay', () => { + it('groups items by day', () => { + const items = [ + { id: 1, timestamp: new Date(2024, 0, 15, 10, 0) }, + { id: 2, timestamp: new Date(2024, 0, 15, 14, 0) }, + { id: 3, timestamp: new Date(2024, 0, 16, 9, 0) }, + { id: 4, timestamp: new Date(2024, 0, 16, 18, 0) }, + { id: 5, timestamp: new Date(2024, 0, 17, 12, 0) }, + ]; + + const buckets = bucketByDay(items, (item) => item.timestamp); + + expect(buckets.get('2024-01-15')?.length).toBe(2); + expect(buckets.get('2024-01-16')?.length).toBe(2); + expect(buckets.get('2024-01-17')?.length).toBe(1); + }); + + it('handles empty array', () => { + const buckets = bucketByDay([], (item: { date: Date }) => item.date); + expect(buckets.size).toBe(0); + }); + }); + + describe('durationInMinutes', () => { + it('calculates duration between dates', () => { + const start = new Date(2024, 0, 15, 10, 0); + const end = new Date(2024, 0, 15, 11, 30); + + expect(durationInMinutes(start, end)).toBe(90); + }); + + it('rounds to nearest minute', () => { + const start = new Date(2024, 0, 15, 10, 0, 0); + const end = new Date(2024, 0, 15, 10, 5, 29); // 5.48 minutes + + expect(durationInMinutes(start, end)).toBe(5); + }); + + it('handles overnight duration', () => { + const start = new Date(2024, 0, 15, 23, 0); + const end = new Date(2024, 0, 16, 7, 0); + + expect(durationInMinutes(start, end)).toBe(480); // 8 hours + }); + }); + + describe('isDateInRange', () => { + it('returns true for date within range', () => { + const start = new Date(2024, 0, 1); + const end = new Date(2024, 0, 31); + const date = new Date(2024, 0, 15); + + expect(isDateInRange(date, { start, end })).toBe(true); + }); + + it('returns true for date at boundaries', () => { + const start = new Date(2024, 0, 1); + const end = new Date(2024, 0, 31); + + expect(isDateInRange(start, { start, end })).toBe(true); + expect(isDateInRange(end, { start, end })).toBe(true); + }); + + it('returns false for date outside range', () => { + const start = new Date(2024, 0, 1); + const end = new Date(2024, 0, 31); + const date = new Date(2024, 1, 1); + + expect(isDateInRange(date, { start, end })).toBe(false); + }); + }); + + describe('isSameDay', () => { + it('returns true for same calendar day', () => { + const date1 = new Date(2024, 0, 15, 10, 0); + const date2 = new Date(2024, 0, 15, 22, 30); + + expect(isSameDay(date1, date2)).toBe(true); + }); + + it('returns false for different days', () => { + const date1 = new Date(2024, 0, 15, 23, 59); + const date2 = new Date(2024, 0, 16, 0, 1); + + expect(isSameDay(date1, date2)).toBe(false); + }); + }); +}); diff --git a/homeflow/packages/healthkit/src/__tests__/sleep-queries.test.ts b/homeflow/packages/healthkit/src/__tests__/sleep-queries.test.ts new file mode 100644 index 0000000..9280d3c --- /dev/null +++ b/homeflow/packages/healthkit/src/__tests__/sleep-queries.test.ts @@ -0,0 +1,298 @@ +/** + * Sleep Query Tests + * + * Tests for sleep stage mapping, night grouping, and aggregation. + */ + +import { SleepStage } from '../types'; +import { + getSleepNightDate, + groupSamplesByNight, + hasDetailedSleepStages, + aggregateSleepNight, + calculateAverageSleepDuration, +} from '../services/queries/sleep'; +import { mapHKSleepValueToStage, HKSleepValue } from '../services/HealthKitService'; +import type { SleepSample, SleepNight } from '../types'; + +describe('Sleep Stage Mapping', () => { + describe('mapHKSleepValueToStage', () => { + it('maps InBed (0) correctly', () => { + expect(mapHKSleepValueToStage(HKSleepValue.InBed)).toBe(SleepStage.InBed); + }); + + it('maps Asleep (1) correctly', () => { + expect(mapHKSleepValueToStage(HKSleepValue.Asleep)).toBe(SleepStage.Asleep); + }); + + it('maps Awake (2) correctly', () => { + expect(mapHKSleepValueToStage(HKSleepValue.Awake)).toBe(SleepStage.Awake); + }); + + it('maps Core (3) correctly', () => { + expect(mapHKSleepValueToStage(HKSleepValue.Core)).toBe(SleepStage.Core); + }); + + it('maps Deep (4) correctly', () => { + expect(mapHKSleepValueToStage(HKSleepValue.Deep)).toBe(SleepStage.Deep); + }); + + it('maps REM (5) correctly', () => { + expect(mapHKSleepValueToStage(HKSleepValue.REM)).toBe(SleepStage.REM); + }); + + it('maps unknown values to Unknown', () => { + expect(mapHKSleepValueToStage(99)).toBe(SleepStage.Unknown); + expect(mapHKSleepValueToStage(-1)).toBe(SleepStage.Unknown); + }); + }); +}); + +describe('Sleep Night Date Calculation', () => { + describe('getSleepNightDate', () => { + it('sleep starting at 10pm belongs to that day', () => { + const date = new Date(2024, 0, 15, 22, 0); // Jan 15, 10pm + expect(getSleepNightDate(date)).toBe('2024-01-15'); + }); + + it('sleep starting at 11pm belongs to that day', () => { + const date = new Date(2024, 0, 15, 23, 30); // Jan 15, 11:30pm + expect(getSleepNightDate(date)).toBe('2024-01-15'); + }); + + it('sleep starting at 6pm belongs to that day', () => { + const date = new Date(2024, 0, 15, 18, 0); // Jan 15, 6pm + expect(getSleepNightDate(date)).toBe('2024-01-15'); + }); + + it('wakeup at 7am belongs to previous day night', () => { + const date = new Date(2024, 0, 16, 7, 0); // Jan 16, 7am + expect(getSleepNightDate(date)).toBe('2024-01-15'); + }); + + it('wakeup at 12pm belongs to previous day night', () => { + const date = new Date(2024, 0, 16, 12, 0); // Jan 16, noon + expect(getSleepNightDate(date)).toBe('2024-01-15'); + }); + + it('wakeup at 5pm belongs to previous day night', () => { + const date = new Date(2024, 0, 16, 17, 0); // Jan 16, 5pm + expect(getSleepNightDate(date)).toBe('2024-01-15'); + }); + + it('handles midnight correctly (belongs to previous day)', () => { + const date = new Date(2024, 0, 16, 0, 0); // Jan 16, midnight + expect(getSleepNightDate(date)).toBe('2024-01-15'); + }); + }); +}); + +describe('Sleep Sample Grouping', () => { + const makeSample = ( + stage: SleepStage, + startHour: number, + durationMinutes: number, + dayOffset: number = 0 + ): SleepSample => { + const startDate = new Date(2024, 0, 15 + dayOffset, startHour, 0); + const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000); + return { + stage, + startDate, + endDate, + durationMinutes, + }; + }; + + describe('groupSamplesByNight', () => { + it('groups samples from same night together', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.Core, 22, 120, 0), // 10pm, 2h + makeSample(SleepStage.Deep, 0, 60, 1), // midnight next day + makeSample(SleepStage.REM, 5, 90, 1), // 5am next day + ]; + + const groups = groupSamplesByNight(samples); + + expect(groups.size).toBe(1); + expect(groups.get('2024-01-15')?.length).toBe(3); + }); + + it('separates samples from different nights', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.Core, 22, 120, 0), // Jan 15 night + makeSample(SleepStage.Core, 22, 120, 1), // Jan 16 night + ]; + + const groups = groupSamplesByNight(samples); + + expect(groups.size).toBe(2); + expect(groups.get('2024-01-15')?.length).toBe(1); + expect(groups.get('2024-01-16')?.length).toBe(1); + }); + }); + + describe('hasDetailedSleepStages', () => { + it('returns true when iOS 16+ stages present', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.Core, 22, 60), + makeSample(SleepStage.Deep, 23, 60), + ]; + + expect(hasDetailedSleepStages(samples)).toBe(true); + }); + + it('returns false for legacy sleep data', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.InBed, 22, 30), + makeSample(SleepStage.Asleep, 22, 420), + ]; + + expect(hasDetailedSleepStages(samples)).toBe(false); + }); + + it('returns true if any detailed stage present', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.Asleep, 22, 300), + makeSample(SleepStage.REM, 3, 30), // One REM segment + ]; + + expect(hasDetailedSleepStages(samples)).toBe(true); + }); + }); +}); + +describe('Sleep Night Aggregation', () => { + const makeSample = ( + stage: SleepStage, + durationMinutes: number + ): SleepSample => ({ + stage, + startDate: new Date(2024, 0, 15, 22, 0), + endDate: new Date(2024, 0, 15, 22, durationMinutes), + durationMinutes, + }); + + describe('aggregateSleepNight', () => { + it('calculates total in bed and asleep time', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.InBed, 30), + makeSample(SleepStage.Core, 120), + makeSample(SleepStage.Deep, 90), + makeSample(SleepStage.REM, 60), + makeSample(SleepStage.Awake, 20), + ]; + + const night = aggregateSleepNight('2024-01-15', samples); + + // Total in bed: 30 + 120 + 90 + 60 + 20 = 320 + expect(night.totalInBedMinutes).toBe(320); + // Total asleep: 120 + 90 + 60 = 270 (excludes InBed and Awake) + expect(night.totalAsleepMinutes).toBe(270); + }); + + it('breaks down by sleep stage', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.Awake, 15), + makeSample(SleepStage.Core, 180), + makeSample(SleepStage.Deep, 60), + makeSample(SleepStage.REM, 90), + ]; + + const night = aggregateSleepNight('2024-01-15', samples); + + expect(night.stages.awake).toBe(15); + expect(night.stages.core).toBe(180); + expect(night.stages.deep).toBe(60); + expect(night.stages.rem).toBe(90); + }); + + it('calculates sleep efficiency', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.Awake, 30), + makeSample(SleepStage.Core, 270), // 4.5 hours + ]; + + const night = aggregateSleepNight('2024-01-15', samples); + + // 270 asleep / 300 in bed = 90% + expect(night.sleepEfficiency).toBe(90); + }); + + it('handles legacy asleep data', () => { + const samples: SleepSample[] = [ + makeSample(SleepStage.InBed, 30), + makeSample(SleepStage.Asleep, 420), // 7 hours + ]; + + const night = aggregateSleepNight('2024-01-15', samples); + + expect(night.hasDetailedStages).toBe(false); + expect(night.stages.asleepUndifferentiated).toBe(420); + expect(night.totalAsleepMinutes).toBe(420); + }); + + it('returns zero efficiency for no data', () => { + const night = aggregateSleepNight('2024-01-15', []); + + expect(night.sleepEfficiency).toBe(0); + expect(night.totalInBedMinutes).toBe(0); + expect(night.totalAsleepMinutes).toBe(0); + }); + }); +}); + +describe('Sleep Statistics', () => { + describe('calculateAverageSleepDuration', () => { + it('calculates average excluding empty nights', () => { + const nights: SleepNight[] = [ + { + date: '2024-01-15', + totalAsleepMinutes: 420, // 7 hours + totalInBedMinutes: 480, + hasDetailedStages: true, + stages: { awake: 0, core: 0, deep: 0, rem: 0, asleepUndifferentiated: 0 }, + samples: [], + sleepEfficiency: 87, + }, + { + date: '2024-01-16', + totalAsleepMinutes: 480, // 8 hours + totalInBedMinutes: 520, + hasDetailedStages: true, + stages: { awake: 0, core: 0, deep: 0, rem: 0, asleepUndifferentiated: 0 }, + samples: [], + sleepEfficiency: 92, + }, + { + date: '2024-01-17', + totalAsleepMinutes: 0, // No data + totalInBedMinutes: 0, + hasDetailedStages: false, + stages: { awake: 0, core: 0, deep: 0, rem: 0, asleepUndifferentiated: 0 }, + samples: [], + sleepEfficiency: 0, + }, + ]; + + // Average of 420 and 480 = 450 + expect(calculateAverageSleepDuration(nights)).toBe(450); + }); + + it('returns 0 for all empty nights', () => { + const nights: SleepNight[] = [ + { + date: '2024-01-15', + totalAsleepMinutes: 0, + totalInBedMinutes: 0, + hasDetailedStages: false, + stages: { awake: 0, core: 0, deep: 0, rem: 0, asleepUndifferentiated: 0 }, + samples: [], + sleepEfficiency: 0, + }, + ]; + + expect(calculateAverageSleepDuration(nights)).toBe(0); + }); + }); +}); diff --git a/homeflow/packages/healthkit/src/index.ts b/homeflow/packages/healthkit/src/index.ts index 8b93992..aa1f1e3 100644 --- a/homeflow/packages/healthkit/src/index.ts +++ b/homeflow/packages/healthkit/src/index.ts @@ -21,9 +21,23 @@ * ); * } * ``` + * + * @example Query activity, sleep, and vitals + * ```tsx + * import { getDailyActivity, getSleep, getVitals, getDateRange } from '@spezivibe/healthkit'; + * + * // Get last 7 days of activity + * const activity = await getDailyActivity(getDateRange(7)); + * + * // Get last 7 nights of sleep + * const sleep = await getSleep(getDateRange(7)); + * + * // Get last 7 days of vitals + * const vitals = await getVitals(getDateRange(7)); + * ``` */ -// Types +// Types - Core export type { HealthKitConfig, HealthSample, @@ -39,6 +53,23 @@ export type { HealthKitContextValue, } from './types'; +// Types - Activity, Sleep, Vitals +export type { + DailyActivityDay, + DailyActivityResult, + ActiveMinutesTiered, + SleepSample, + SleepNight, + SleepResult, + VitalsSample, + VitalsDay, + VitalsResult, + PermissionResult, +} from './types'; + +// Enums +export { SleepStage } from './types'; + // Sample Types export { SampleType, @@ -48,10 +79,33 @@ export { getLabelForType, } from './sample-types'; -// Services -export { HealthKitService } from './services'; +// Services - Core +export { HealthKitService, HKSleepValue, mapHKSleepValueToStage } from './services'; export type { IHealthKitService } from './services'; +// Services - Query Functions +export { + // Activity + getDailyActivity, + getRecentActivity, + calculateSedentaryMinutes, + // Sleep + getSleep, + getRecentSleep, + getSleepNightDate, + groupSamplesByNight, + hasDetailedSleepStages, + aggregateSleepNight, + calculateAverageSleepDuration, + // Vitals + getVitals, + getRecentVitals, + getHeartRateSamples, + calculateAverageRestingHR, + calculateAverageHRV, + FUTURE_WATCH_METRICS, +} from './services'; + // Providers export { HealthKitProvider, useHealthKitContext } from './providers'; export type { HealthKitProviderProps } from './providers'; @@ -71,5 +125,22 @@ export { } from './ui'; export type { ExpoGoFallbackProps } from './ui'; -// Utils +// Utils - Expo detection export { isExpoGo, isStandalone, getExpoGoMessage } from './utils'; + +// Utils - Date helpers +export { + type DateRange, + getDateRange, + getDayBoundaries, + formatDateKey, + parseDateKey, + getDateKeysInRange, + bucketByDay, + durationInMinutes, + isDateInRange, + daysAgo, + isSameDay, + startOfToday, + endOfToday, +} from './utils'; diff --git a/homeflow/packages/healthkit/src/services/HealthKitService.ts b/homeflow/packages/healthkit/src/services/HealthKitService.ts index 6e68457..4d78149 100644 --- a/homeflow/packages/healthkit/src/services/HealthKitService.ts +++ b/homeflow/packages/healthkit/src/services/HealthKitService.ts @@ -10,7 +10,11 @@ import type { HealthSample, HealthStatistics, HealthQueryOptions, + SleepSample, + SleepStage, + PermissionResult, } from '../types'; +import { SleepStage as SleepStageEnum } from '../types'; import { SampleType, getUnitForType } from '../sample-types'; import type { QuantityTypeIdentifier, @@ -31,6 +35,42 @@ if (Platform.OS === 'ios') { } } +/** + * Apple HealthKit sleep analysis values + * These map to HKCategoryValueSleepAnalysis in HealthKit + */ +export enum HKSleepValue { + InBed = 0, + Asleep = 1, + Awake = 2, + // iOS 16+ sleep stages + Core = 3, + Deep = 4, + REM = 5, +} + +/** + * Map HealthKit sleep value to our SleepStage enum + */ +export function mapHKSleepValueToStage(value: number): SleepStage { + switch (value) { + case HKSleepValue.InBed: + return SleepStageEnum.InBed; + case HKSleepValue.Asleep: + return SleepStageEnum.Asleep; + case HKSleepValue.Awake: + return SleepStageEnum.Awake; + case HKSleepValue.Core: + return SleepStageEnum.Core; + case HKSleepValue.Deep: + return SleepStageEnum.Deep; + case HKSleepValue.REM: + return SleepStageEnum.REM; + default: + return SleepStageEnum.Unknown; + } +} + /** * HealthKit Service Interface */ @@ -40,11 +80,16 @@ export interface IHealthKitService { readTypes: SampleType[], writeTypes?: SampleType[] ): Promise; + requestAuthorizationWithStatus( + readTypes: SampleType[], + writeTypes?: SampleType[] + ): Promise; getMostRecentSample(type: SampleType): Promise; querySamples( type: SampleType, options: HealthQueryOptions ): Promise; + querySleepSamples(options: HealthQueryOptions): Promise; getStatistics( type: SampleType, options: HealthQueryOptions @@ -85,6 +130,72 @@ class HealthKitServiceImpl implements IHealthKitService { } } + /** + * Request authorization with detailed status information + * Note: HealthKit privacy model means read permissions always appear as "not determined" + * even when granted. We can only reliably check write permission status. + */ + async requestAuthorizationWithStatus( + readTypes: SampleType[], + writeTypes: SampleType[] = [] + ): Promise { + if (!this.isAvailable() || !healthKit) { + return { + ok: false, + granted: [], + denied: [], + notDetermined: [...readTypes, ...writeTypes], + }; + } + + try { + const write = writeTypes as unknown as SampleTypeIdentifierWriteable[]; + const read = readTypes as unknown as ObjectTypeIdentifier[]; + + await healthKit.requestAuthorization(write, read); + + // Check authorization status for write types + // Note: For read types, HealthKit always returns "not determined" for privacy + const granted: string[] = []; + const denied: string[] = []; + const notDetermined: string[] = [...readTypes]; // Read types are always "unknown" + + for (const writeType of writeTypes) { + try { + const status = await healthKit.authorizationStatusFor( + writeType as unknown as SampleTypeIdentifierWriteable + ); + // Status: 0 = notDetermined, 1 = sharingDenied, 2 = sharingAuthorized + if (status === 2) { + granted.push(writeType); + } else if (status === 1) { + denied.push(writeType); + } else { + notDetermined.push(writeType); + } + } catch { + // If we can't get status, assume not determined + notDetermined.push(writeType); + } + } + + return { + ok: true, + granted, + denied, + notDetermined, + }; + } catch (error) { + console.error('HealthKit authorization failed:', error); + return { + ok: false, + granted: [], + denied: [], + notDetermined: [...readTypes, ...writeTypes], + }; + } + } + /** * Get the most recent sample for a given type */ @@ -154,6 +265,62 @@ class HealthKitServiceImpl implements IHealthKitService { } } + /** + * Query sleep analysis samples (category type, not quantity) + * Returns sleep segments with their stage and duration + */ + async querySleepSamples(options: HealthQueryOptions): Promise { + if (!this.isAvailable() || !healthKit) { + return []; + } + + try { + // queryCategorySamples has different signatures in iOS vs non-iOS type definitions + // On iOS it accepts options, use type assertion to handle this + const queryCategorySamplesFn = healthKit.queryCategorySamples as ( + identifier: string, + options?: { + filter?: { startDate?: Date; endDate?: Date }; + limit?: number; + ascending?: boolean; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => Promise; + + const samples = await queryCategorySamplesFn( + 'HKCategoryTypeIdentifierSleepAnalysis', + { + filter: { + startDate: options.startDate, + endDate: options.endDate, + }, + limit: options.limit, + ascending: options.ascending ?? true, + } + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return samples.map((sample: any) => { + const startDate = new Date(sample.startDate); + const endDate = new Date(sample.endDate); + const durationMinutes = Math.round( + (endDate.getTime() - startDate.getTime()) / (1000 * 60) + ); + + return { + stage: mapHKSleepValueToStage(sample.value), + startDate, + endDate, + durationMinutes, + sourceName: sample.sourceRevision?.source?.name, + }; + }); + } catch (error) { + console.error('Failed to query sleep samples:', error); + return []; + } + } + /** * Get aggregated statistics for a date range */ diff --git a/homeflow/packages/healthkit/src/services/index.ts b/homeflow/packages/healthkit/src/services/index.ts index 1017691..e66944e 100644 --- a/homeflow/packages/healthkit/src/services/index.ts +++ b/homeflow/packages/healthkit/src/services/index.ts @@ -1,2 +1,25 @@ -export { HealthKitService } from './HealthKitService'; +export { HealthKitService, HKSleepValue, mapHKSleepValueToStage } from './HealthKitService'; export type { IHealthKitService } from './HealthKitService'; + +// Query modules +export { + // Activity + getDailyActivity, + getRecentActivity, + calculateSedentaryMinutes, + // Sleep + getSleep, + getRecentSleep, + getSleepNightDate, + groupSamplesByNight, + hasDetailedSleepStages, + aggregateSleepNight, + calculateAverageSleepDuration, + // Vitals + getVitals, + getRecentVitals, + getHeartRateSamples, + calculateAverageRestingHR, + calculateAverageHRV, + FUTURE_WATCH_METRICS, +} from './queries'; diff --git a/homeflow/packages/healthkit/src/services/queries/activity.ts b/homeflow/packages/healthkit/src/services/queries/activity.ts new file mode 100644 index 0000000..03e7efb --- /dev/null +++ b/homeflow/packages/healthkit/src/services/queries/activity.ts @@ -0,0 +1,180 @@ +/** + * Activity Query Module + * + * Provides functions to query and aggregate daily physical activity data + * from HealthKit including steps, exercise time, and estimated sedentary time. + */ + +import { HealthKitService } from '../HealthKitService'; +import { SampleType } from '../../sample-types'; +import type { DailyActivityDay, DailyActivityResult } from '../../types'; +import { + type DateRange, + getDateKeysInRange, + formatDateKey, + getDayBoundaries, + parseDateKey, +} from '../../utils'; + +/** + * Assumed waking hours per day for sedentary time calculation. + * 16 hours = 24 hours - 8 hours of sleep + */ +const WAKING_HOURS_PER_DAY = 16; +const WAKING_MINUTES_PER_DAY = WAKING_HOURS_PER_DAY * 60; + +/** + * Get daily statistics for a cumulative quantity type + */ +async function getDailyStatistics( + type: SampleType, + range: DateRange +): Promise> { + const results = new Map(); + const dateKeys = getDateKeysInRange(range); + + // Query each day individually for accurate daily totals + for (const dateKey of dateKeys) { + const dayBounds = getDayBoundaries(parseDateKey(dateKey)); + const stats = await HealthKitService.getStatistics(type, { + startDate: dayBounds.start, + endDate: dayBounds.end, + }); + results.set(dateKey, stats?.sum ?? 0); + } + + return results; +} + +/** + * Get daily average for a non-cumulative quantity type (like heart rate) + */ +async function getDailyAverages( + type: SampleType, + range: DateRange +): Promise> { + const results = new Map(); + const dateKeys = getDateKeysInRange(range); + + for (const dateKey of dateKeys) { + const dayBounds = getDayBoundaries(parseDateKey(dateKey)); + const stats = await HealthKitService.getStatistics(type, { + startDate: dayBounds.start, + endDate: dayBounds.end, + }); + results.set(dateKey, stats?.average ?? 0); + } + + return results; +} + +/** + * Calculate estimated sedentary minutes for a day + * + * Sedentary time is approximated as: + * Waking hours (16h) - Exercise time - Stand time + * + * Limitations: + * - This is a rough estimate since HealthKit doesn't track sedentary time directly + * - Stand time from Apple Watch is in hours (we convert to minutes) + * - Move time is not subtracted as it overlaps with exercise/stand + * - Does not account for sleep variations + * + * @param exerciseMinutes Apple Exercise Time (high-intensity activity) + * @param standMinutes Apple Stand Time (converted from hours) + * @returns Estimated sedentary minutes (minimum 0) + */ +export function calculateSedentaryMinutes( + exerciseMinutes: number, + standMinutes: number +): number { + // Stand time counts when you stand for at least 1 minute in an hour + // We estimate ~5 minutes of activity per stand hour + const estimatedStandActivity = standMinutes; + + // Sedentary = Waking time - active time + // We don't double-count exercise time with stand time + const activeMinutes = Math.max(exerciseMinutes, estimatedStandActivity); + const sedentary = WAKING_MINUTES_PER_DAY - activeMinutes; + + return Math.max(0, Math.round(sedentary)); +} + +/** + * Get daily activity data for a date range + * + * @param range Date range to query + * @returns DailyActivityResult with array of daily summaries + */ +export async function getDailyActivity( + range: DateRange +): Promise { + // Fetch all metrics in parallel for efficiency + const [ + stepsMap, + exerciseMap, + moveMap, + standMap, + energyMap, + distanceMap, + ] = await Promise.all([ + getDailyStatistics(SampleType.stepCount, range), + getDailyStatistics(SampleType.appleExerciseTime, range), + getDailyStatistics(SampleType.appleMoveTime, range), + getDailyStatistics(SampleType.appleStandTime, range), + getDailyStatistics(SampleType.activeEnergyBurned, range), + getDailyStatistics(SampleType.distanceWalkingRunning, range), + ]); + + const dateKeys = getDateKeysInRange(range); + const days: DailyActivityDay[] = dateKeys.map((dateKey) => { + const steps = Math.round(stepsMap.get(dateKey) ?? 0); + const exerciseMinutes = Math.round(exerciseMap.get(dateKey) ?? 0); + const moveMinutes = Math.round(moveMap.get(dateKey) ?? 0); + // Stand time is tracked in hours by Apple Watch, but reported in minutes + const standMinutes = Math.round(standMap.get(dateKey) ?? 0); + const activeEnergyBurned = Math.round(energyMap.get(dateKey) ?? 0); + const distanceWalkingRunning = Math.round(distanceMap.get(dateKey) ?? 0); + + const sedentaryMinutes = calculateSedentaryMinutes( + exerciseMinutes, + standMinutes + ); + + return { + date: dateKey, + steps, + exerciseMinutes, + moveMinutes, + standMinutes, + sedentaryMinutes, + activeEnergyBurned, + distanceWalkingRunning, + }; + }); + + return { + days, + range: { + start: range.start, + end: range.end, + }, + }; +} + +/** + * Get activity summary for the last N days + * + * @param days Number of days to include (default 7) + * @returns DailyActivityResult + */ +export async function getRecentActivity( + days: number = 7 +): Promise { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - (days - 1)); + start.setHours(0, 0, 0, 0); + + return getDailyActivity({ start, end }); +} diff --git a/homeflow/packages/healthkit/src/services/queries/index.ts b/homeflow/packages/healthkit/src/services/queries/index.ts new file mode 100644 index 0000000..654b711 --- /dev/null +++ b/homeflow/packages/healthkit/src/services/queries/index.ts @@ -0,0 +1,33 @@ +/** + * HealthKit Query Modules + * + * Provides high-level query functions for activity, sleep, and vitals data. + */ + +// Activity queries +export { + getDailyActivity, + getRecentActivity, + calculateSedentaryMinutes, +} from './activity'; + +// Sleep queries +export { + getSleep, + getRecentSleep, + getSleepNightDate, + groupSamplesByNight, + hasDetailedSleepStages, + aggregateSleepNight, + calculateAverageSleepDuration, +} from './sleep'; + +// Vitals queries +export { + getVitals, + getRecentVitals, + getHeartRateSamples, + calculateAverageRestingHR, + calculateAverageHRV, + FUTURE_WATCH_METRICS, +} from './vitals'; diff --git a/homeflow/packages/healthkit/src/services/queries/sleep.ts b/homeflow/packages/healthkit/src/services/queries/sleep.ts new file mode 100644 index 0000000..2162e80 --- /dev/null +++ b/homeflow/packages/healthkit/src/services/queries/sleep.ts @@ -0,0 +1,268 @@ +/** + * Sleep Query Module + * + * Provides functions to query and aggregate sleep data from HealthKit. + * Handles both iOS 16+ detailed sleep stages and legacy asleep/inBed data. + */ + +import { HealthKitService } from '../HealthKitService'; +import type { SleepSample, SleepNight, SleepResult } from '../../types'; +import { SleepStage } from '../../types'; +import { + type DateRange, + formatDateKey, + getDateKeysInRange, + durationInMinutes, +} from '../../utils'; + +/** + * Determine the "night" a sleep sample belongs to. + * Sleep that starts after 6pm belongs to that day's night. + * Sleep that starts before 6pm belongs to the previous day's night. + * + * This handles cases where people go to bed late (e.g., 11pm on Monday = Monday night) + * or wake up late (e.g., 10am wakeup on Tuesday still = Monday night) + * + * @param startDate Start time of the sleep sample + * @returns Date key (YYYY-MM-DD) for the night this sleep belongs to + */ +export function getSleepNightDate(startDate: Date): string { + const hour = startDate.getHours(); + + // If sleep starts between midnight (0) and 6pm (18), it belongs to previous day's night + if (hour < 18) { + const previousDay = new Date(startDate); + previousDay.setDate(previousDay.getDate() - 1); + return formatDateKey(previousDay); + } + + // Sleep starting at 6pm or later belongs to this day's night + return formatDateKey(startDate); +} + +/** + * Group sleep samples by night + * + * @param samples Array of sleep samples + * @returns Map of night date key to array of samples + */ +export function groupSamplesByNight( + samples: SleepSample[] +): Map { + const nights = new Map(); + + for (const sample of samples) { + const nightKey = getSleepNightDate(sample.startDate); + + if (!nights.has(nightKey)) { + nights.set(nightKey, []); + } + nights.get(nightKey)!.push(sample); + } + + return nights; +} + +/** + * Check if any samples in the array have detailed sleep stages (iOS 16+) + * Detailed stages include Core, Deep, and REM + * + * @param samples Array of sleep samples + * @returns True if detailed stages are present + */ +export function hasDetailedSleepStages(samples: SleepSample[]): boolean { + return samples.some( + (s) => + s.stage === SleepStage.Core || + s.stage === SleepStage.Deep || + s.stage === SleepStage.REM + ); +} + +/** + * Aggregate sleep samples into a SleepNight summary + * + * @param date Night date (YYYY-MM-DD) + * @param samples Sleep samples for this night + * @returns SleepNight summary + */ +export function aggregateSleepNight( + date: string, + samples: SleepSample[] +): SleepNight { + // Sort samples by start time + const sortedSamples = [...samples].sort( + (a, b) => a.startDate.getTime() - b.startDate.getTime() + ); + + // Initialize stage totals + const stages = { + awake: 0, + core: 0, + deep: 0, + rem: 0, + asleepUndifferentiated: 0, + }; + + let totalInBedMinutes = 0; + let totalAsleepMinutes = 0; + + for (const sample of sortedSamples) { + const duration = sample.durationMinutes; + + switch (sample.stage) { + case SleepStage.InBed: + totalInBedMinutes += duration; + // InBed doesn't count as asleep + break; + case SleepStage.Awake: + stages.awake += duration; + // Awake is in bed but not asleep + totalInBedMinutes += duration; + break; + case SleepStage.Asleep: + stages.asleepUndifferentiated += duration; + totalAsleepMinutes += duration; + totalInBedMinutes += duration; + break; + case SleepStage.Core: + stages.core += duration; + totalAsleepMinutes += duration; + totalInBedMinutes += duration; + break; + case SleepStage.Deep: + stages.deep += duration; + totalAsleepMinutes += duration; + totalInBedMinutes += duration; + break; + case SleepStage.REM: + stages.rem += duration; + totalAsleepMinutes += duration; + totalInBedMinutes += duration; + break; + default: + // Unknown stages - count as in bed but not asleep + totalInBedMinutes += duration; + } + } + + // Calculate sleep efficiency + const sleepEfficiency = + totalInBedMinutes > 0 + ? Math.round((totalAsleepMinutes / totalInBedMinutes) * 100) + : 0; + + return { + date, + totalInBedMinutes: Math.round(totalInBedMinutes), + totalAsleepMinutes: Math.round(totalAsleepMinutes), + hasDetailedStages: hasDetailedSleepStages(sortedSamples), + stages: { + awake: Math.round(stages.awake), + core: Math.round(stages.core), + deep: Math.round(stages.deep), + rem: Math.round(stages.rem), + asleepUndifferentiated: Math.round(stages.asleepUndifferentiated), + }, + samples: sortedSamples, + sleepEfficiency, + }; +} + +/** + * Get sleep data for a date range + * + * @param range Date range to query (extended internally to capture full nights) + * @returns SleepResult with array of nightly summaries + */ +export async function getSleep(range: DateRange): Promise { + // Extend the range to capture sleep that starts the evening before + // and ends the morning after + const extendedStart = new Date(range.start); + extendedStart.setDate(extendedStart.getDate() - 1); + extendedStart.setHours(18, 0, 0, 0); // Start from 6pm previous day + + const extendedEnd = new Date(range.end); + extendedEnd.setDate(extendedEnd.getDate() + 1); + extendedEnd.setHours(18, 0, 0, 0); // End at 6pm next day + + // Query sleep samples + const samples = await HealthKitService.querySleepSamples({ + startDate: extendedStart, + endDate: extendedEnd, + }); + + // Group samples by night + const nightsMap = groupSamplesByNight(samples); + + // Get the date keys we actually want (from the original range) + const requestedDateKeys = getDateKeysInRange(range); + + // Aggregate into nightly summaries, only for requested dates + const nights: SleepNight[] = []; + for (const dateKey of requestedDateKeys) { + const nightSamples = nightsMap.get(dateKey) || []; + if (nightSamples.length > 0) { + nights.push(aggregateSleepNight(dateKey, nightSamples)); + } else { + // Include empty night entry for consistency + nights.push({ + date: dateKey, + totalInBedMinutes: 0, + totalAsleepMinutes: 0, + hasDetailedStages: false, + stages: { + awake: 0, + core: 0, + deep: 0, + rem: 0, + asleepUndifferentiated: 0, + }, + samples: [], + sleepEfficiency: 0, + }); + } + } + + return { + nights, + range: { + start: range.start, + end: range.end, + }, + }; +} + +/** + * Get sleep data for the last N nights + * + * @param nights Number of nights to include (default 7) + * @returns SleepResult + */ +export async function getRecentSleep(nights: number = 7): Promise { + const end = new Date(); + end.setHours(23, 59, 59, 999); + + const start = new Date(); + start.setDate(start.getDate() - (nights - 1)); + start.setHours(0, 0, 0, 0); + + return getSleep({ start, end }); +} + +/** + * Calculate average sleep duration over a period + * + * @param nights Array of SleepNight data + * @returns Average minutes asleep (excluding nights with no data) + */ +export function calculateAverageSleepDuration(nights: SleepNight[]): number { + const nightsWithData = nights.filter((n) => n.totalAsleepMinutes > 0); + if (nightsWithData.length === 0) return 0; + + const totalMinutes = nightsWithData.reduce( + (sum, n) => sum + n.totalAsleepMinutes, + 0 + ); + return Math.round(totalMinutes / nightsWithData.length); +} diff --git a/homeflow/packages/healthkit/src/services/queries/vitals.ts b/homeflow/packages/healthkit/src/services/queries/vitals.ts new file mode 100644 index 0000000..e92205b --- /dev/null +++ b/homeflow/packages/healthkit/src/services/queries/vitals.ts @@ -0,0 +1,257 @@ +/** + * Vitals Query Module + * + * Provides functions to query and aggregate vital signs data from HealthKit + * including heart rate, HRV, respiratory rate, and other Apple Watch metrics. + */ + +import { HealthKitService } from '../HealthKitService'; +import { SampleType } from '../../sample-types'; +import type { VitalsDay, VitalsResult, HealthSample } from '../../types'; +import { + type DateRange, + getDateKeysInRange, + getDayBoundaries, + parseDateKey, + bucketByDay, +} from '../../utils'; + +/** + * Get heart rate statistics for a single day + */ +async function getDailyHeartRateStats( + dateKey: string +): Promise { + const dayBounds = getDayBoundaries(parseDateKey(dateKey)); + + // Query all heart rate samples for the day + const samples = await HealthKitService.querySamples(SampleType.heartRate, { + startDate: dayBounds.start, + endDate: dayBounds.end, + }); + + if (samples.length === 0) { + return { + min: null, + max: null, + average: null, + sampleCount: 0, + }; + } + + const values = samples.map((s) => s.value); + const sum = values.reduce((a, b) => a + b, 0); + + return { + min: Math.round(Math.min(...values)), + max: Math.round(Math.max(...values)), + average: Math.round(sum / values.length), + sampleCount: values.length, + }; +} + +/** + * Get the most recent sample of a type for a day + * Returns null if no sample exists for that day + */ +async function getDailyMostRecent( + type: SampleType, + dateKey: string +): Promise { + const dayBounds = getDayBoundaries(parseDateKey(dateKey)); + + const samples = await HealthKitService.querySamples(type, { + startDate: dayBounds.start, + endDate: dayBounds.end, + ascending: false, // Most recent first + limit: 1, + }); + + if (samples.length === 0) { + return null; + } + + return samples[0].value; +} + +/** + * Get vitals data for a date range + * + * @param range Date range to query + * @returns VitalsResult with array of daily summaries + */ +export async function getVitals(range: DateRange): Promise { + const dateKeys = getDateKeysInRange(range); + + // Process each day + const days: VitalsDay[] = await Promise.all( + dateKeys.map(async (dateKey) => { + // Fetch all vitals for this day in parallel + const [heartRateStats, restingHR, hrv, respiratoryRate, oxygenSat] = + await Promise.all([ + getDailyHeartRateStats(dateKey), + getDailyMostRecent(SampleType.restingHeartRate, dateKey), + getDailyMostRecent(SampleType.heartRateVariabilitySDNN, dateKey), + getDailyMostRecent(SampleType.respiratoryRate, dateKey), + getDailyMostRecent(SampleType.oxygenSaturation, dateKey), + ]); + + return { + date: dateKey, + heartRate: heartRateStats, + restingHeartRate: restingHR !== null ? Math.round(restingHR) : null, + hrv: hrv !== null ? Math.round(hrv) : null, + respiratoryRate: + respiratoryRate !== null ? Math.round(respiratoryRate * 10) / 10 : null, + oxygenSaturation: + oxygenSat !== null ? Math.round(oxygenSat * 100) / 100 : null, + }; + }) + ); + + return { + days, + range: { + start: range.start, + end: range.end, + }, + }; +} + +/** + * Get vitals data for the last N days + * + * @param days Number of days to include (default 7) + * @returns VitalsResult + */ +export async function getRecentVitals(days: number = 7): Promise { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - (days - 1)); + start.setHours(0, 0, 0, 0); + + return getVitals({ start, end }); +} + +/** + * Get all heart rate samples for a date range (for detailed analysis) + * + * @param range Date range to query + * @returns Array of HealthSample with heart rate readings + */ +export async function getHeartRateSamples( + range: DateRange +): Promise { + return HealthKitService.querySamples(SampleType.heartRate, { + startDate: range.start, + endDate: range.end, + ascending: true, + }); +} + +/** + * Calculate average resting heart rate over a period + * + * @param days Array of VitalsDay data + * @returns Average resting HR (excluding days with no data) + */ +export function calculateAverageRestingHR(days: VitalsDay[]): number | null { + const daysWithData = days.filter((d) => d.restingHeartRate !== null); + if (daysWithData.length === 0) return null; + + const sum = daysWithData.reduce((s, d) => s + (d.restingHeartRate ?? 0), 0); + return Math.round(sum / daysWithData.length); +} + +/** + * Calculate average HRV over a period + * + * @param days Array of VitalsDay data + * @returns Average HRV in ms (excluding days with no data) + */ +export function calculateAverageHRV(days: VitalsDay[]): number | null { + const daysWithData = days.filter((d) => d.hrv !== null); + if (daysWithData.length === 0) return null; + + const sum = daysWithData.reduce((s, d) => s + (d.hrv ?? 0), 0); + return Math.round(sum / daysWithData.length); +} + +/** + * List of additional Apple Watch metrics that could be added in the future. + * These are not currently implemented but documented for reference. + */ +export const FUTURE_WATCH_METRICS = [ + { + type: 'HKQuantityTypeIdentifierOxygenSaturation', + name: 'Blood Oxygen (SpO2)', + notes: 'Requires Apple Watch Series 6+', + unit: '%', + }, + { + type: 'HKQuantityTypeIdentifierVO2Max', + name: 'VO2 Max', + notes: 'Cardio fitness level', + unit: 'mL/kg/min', + }, + { + type: 'HKQuantityTypeIdentifierWalkingHeartRateAverage', + name: 'Walking Heart Rate Average', + notes: 'Average HR during walks', + unit: 'bpm', + }, + { + type: 'HKQuantityTypeIdentifierAppleWalkingSteadiness', + name: 'Walking Steadiness', + notes: 'Fall risk indicator (iOS 15+)', + unit: '%', + }, + { + type: 'HKQuantityTypeIdentifierWalkingAsymmetryPercentage', + name: 'Walking Asymmetry', + notes: 'Gait analysis', + unit: '%', + }, + { + type: 'HKQuantityTypeIdentifierWalkingDoubleSupportPercentage', + name: 'Double Support Time', + notes: 'Mobility indicator', + unit: '%', + }, + { + type: 'HKQuantityTypeIdentifierWalkingSpeed', + name: 'Walking Speed', + notes: 'Average walking pace', + unit: 'm/s', + }, + { + type: 'HKQuantityTypeIdentifierWalkingStepLength', + name: 'Step Length', + notes: 'Average step length', + unit: 'cm', + }, + { + type: 'HKQuantityTypeIdentifierStairAscentSpeed', + name: 'Stair Ascent Speed', + notes: 'How fast you climb stairs', + unit: 'floors/min', + }, + { + type: 'HKQuantityTypeIdentifierStairDescentSpeed', + name: 'Stair Descent Speed', + notes: 'How fast you descend stairs', + unit: 'floors/min', + }, + { + type: 'HKQuantityTypeIdentifierAppleSleepingWristTemperature', + name: 'Wrist Temperature', + notes: 'Apple Watch Series 8+ only', + unit: 'degC', + }, + { + type: 'HKCategoryTypeIdentifierAtrialFibrillationBurden', + name: 'AFib History', + notes: 'Requires AFib History feature enabled', + unit: '%', + }, +] as const; diff --git a/homeflow/packages/healthkit/src/types.ts b/homeflow/packages/healthkit/src/types.ts index 734b887..a24cdf3 100644 --- a/homeflow/packages/healthkit/src/types.ts +++ b/homeflow/packages/healthkit/src/types.ts @@ -162,3 +162,226 @@ export interface HealthKitContextValue { /** Refresh all data */ refresh: () => void; } + +// ============================================================================ +// Daily Activity Types +// ============================================================================ + +/** + * Daily activity summary for a single day + */ +export interface DailyActivityDay { + /** Date in YYYY-MM-DD format */ + date: string; + /** Total step count for the day */ + steps: number; + /** Apple Exercise minutes (brisk activity detected by Apple Watch) */ + exerciseMinutes: number; + /** Apple Move minutes (any movement activity) */ + moveMinutes: number; + /** Apple Stand hours converted to minutes (for display consistency) */ + standMinutes: number; + /** + * Estimated sedentary minutes. + * Calculated as: waking hours (16h) - exercise - stand time + * This is an approximation; HealthKit doesn't track sedentary time directly. + */ + sedentaryMinutes: number; + /** Active energy burned in kcal */ + activeEnergyBurned: number; + /** Distance walked/run in meters */ + distanceWalkingRunning: number; +} + +/** + * Tiered breakdown of active minutes + * Note: Apple Watch tracks Exercise Time (high intensity) and Move Time (any movement) + * but doesn't provide intermediate tiers. This structure supports future expansion. + */ +export interface ActiveMinutesTiered { + /** High-intensity exercise (from appleExerciseTime) */ + exercise: number; + /** General movement (from appleMoveTime) */ + move: number; + /** Combined total */ + total: number; +} + +// ============================================================================ +// Sleep Types +// ============================================================================ + +/** + * Sleep stage categories + * iOS 16+ provides detailed stages; older versions only have inBed/asleep + */ +export enum SleepStage { + /** User is awake (during the night) */ + Awake = 'awake', + /** Core sleep (light sleep) - iOS 16+ only */ + Core = 'core', + /** Deep sleep (slow-wave) - iOS 16+ only */ + Deep = 'deep', + /** REM sleep - iOS 16+ only */ + REM = 'rem', + /** In bed but sleep state unknown (legacy) */ + InBed = 'inBed', + /** Asleep but stage unknown (legacy or when stages unavailable) */ + Asleep = 'asleep', + /** Unknown or unrecognized stage */ + Unknown = 'unknown', +} + +/** + * A single sleep sample/segment + */ +export interface SleepSample { + /** Sleep stage for this segment */ + stage: SleepStage; + /** Start time of this segment */ + startDate: Date; + /** End time of this segment */ + endDate: Date; + /** Duration in minutes */ + durationMinutes: number; + /** Source app/device name */ + sourceName?: string; +} + +/** + * Sleep data for a single night + * Note: A "night" is typically from ~6pm to ~12pm next day to capture late sleepers + */ +export interface SleepNight { + /** Date of the night (YYYY-MM-DD format, using the date when sleep started) */ + date: string; + /** Total time in bed (minutes) */ + totalInBedMinutes: number; + /** Total time asleep (minutes) - all stages except awake and inBed */ + totalAsleepMinutes: number; + /** Whether detailed sleep stages are available (iOS 16+) */ + hasDetailedStages: boolean; + /** Breakdown by sleep stage */ + stages: { + awake: number; + core: number; + deep: number; + rem: number; + /** Legacy "asleep" when stages unavailable */ + asleepUndifferentiated: number; + }; + /** Raw sleep samples for this night */ + samples: SleepSample[]; + /** Sleep efficiency: asleep / inBed * 100 */ + sleepEfficiency: number; +} + +// ============================================================================ +// Vitals Types +// ============================================================================ + +/** + * A single vitals reading + */ +export interface VitalsSample { + /** Type of vital (heartRate, respiratoryRate, etc.) */ + type: string; + /** Measured value */ + value: number; + /** Unit of measurement */ + unit: string; + /** When the measurement was taken */ + timestamp: Date; + /** Source device/app */ + sourceName?: string; +} + +/** + * Daily vitals summary + */ +export interface VitalsDay { + /** Date in YYYY-MM-DD format */ + date: string; + /** Heart rate statistics (bpm) */ + heartRate: { + min: number | null; + max: number | null; + average: number | null; + /** Number of readings */ + sampleCount: number; + }; + /** Resting heart rate (bpm) - typically one reading per day from Apple Watch */ + restingHeartRate: number | null; + /** Heart rate variability SDNN (ms) */ + hrv: number | null; + /** Respiratory rate (breaths/min) */ + respiratoryRate: number | null; + /** Blood oxygen saturation (%) - if available */ + oxygenSaturation: number | null; +} + +// ============================================================================ +// Permission Types +// ============================================================================ + +/** + * Result of a permission request with detailed status + */ +export interface PermissionResult { + /** Whether the overall request succeeded (user saw the prompt) */ + ok: boolean; + /** Types that were granted access */ + granted: string[]; + /** Types that were denied access */ + denied: string[]; + /** + * Types with undetermined status. + * Note: HealthKit doesn't tell us if read access was granted; + * it only indicates "not determined" vs "sharing denied" for write access. + * Read permissions always appear as "not determined" even when granted. + */ + notDetermined: string[]; +} + +// ============================================================================ +// Query Result Types +// ============================================================================ + +/** + * Result of a daily activity query + */ +export interface DailyActivityResult { + /** Array of daily activity summaries */ + days: DailyActivityDay[]; + /** Date range that was queried */ + range: { + start: Date; + end: Date; + }; +} + +/** + * Result of a sleep query + */ +export interface SleepResult { + /** Array of sleep nights */ + nights: SleepNight[]; + /** Date range that was queried */ + range: { + start: Date; + end: Date; + }; +} + +/** + * Result of a vitals query + */ +export interface VitalsResult { + /** Array of daily vitals summaries */ + days: VitalsDay[]; + /** Date range that was queried */ + range: { + start: Date; + end: Date; + }; +} diff --git a/homeflow/packages/healthkit/src/utils/date.ts b/homeflow/packages/healthkit/src/utils/date.ts new file mode 100644 index 0000000..979f078 --- /dev/null +++ b/homeflow/packages/healthkit/src/utils/date.ts @@ -0,0 +1,172 @@ +/** + * Date Utilities for HealthKit + * + * Helper functions for date range calculations, bucketing samples by day, + * and handling timezone considerations for health data. + */ + +/** + * Date range with start and end dates + */ +export interface DateRange { + start: Date; + end: Date; +} + +/** + * Get a date range for the last N days (including today) + * @param days Number of days to include (7 = last 7 days including today) + * @returns DateRange with start at midnight N-1 days ago and end at current time + */ +export function getDateRange(days: number): DateRange { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - (days - 1)); + start.setHours(0, 0, 0, 0); + return { start, end }; +} + +/** + * Get the start and end of a specific day in local timezone + * @param date Any date within the desired day + * @returns DateRange from 00:00:00.000 to 23:59:59.999 + */ +export function getDayBoundaries(date: Date): DateRange { + const start = new Date(date); + start.setHours(0, 0, 0, 0); + + const end = new Date(date); + end.setHours(23, 59, 59, 999); + + return { start, end }; +} + +/** + * Format a date as YYYY-MM-DD string (local timezone) + * @param date Date to format + * @returns String in YYYY-MM-DD format + */ +export function formatDateKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * Parse a YYYY-MM-DD string into a Date (at midnight local time) + * @param dateKey String in YYYY-MM-DD format + * @returns Date at midnight local time + */ +export function parseDateKey(dateKey: string): Date { + const [year, month, day] = dateKey.split('-').map(Number); + return new Date(year, month - 1, day, 0, 0, 0, 0); +} + +/** + * Generate an array of date keys for a range + * @param range Date range to generate keys for + * @returns Array of YYYY-MM-DD strings for each day in the range + */ +export function getDateKeysInRange(range: DateRange): string[] { + const keys: string[] = []; + const current = new Date(range.start); + current.setHours(0, 0, 0, 0); + + const endDate = new Date(range.end); + endDate.setHours(23, 59, 59, 999); + + while (current <= endDate) { + keys.push(formatDateKey(current)); + current.setDate(current.getDate() + 1); + } + + return keys; +} + +/** + * Bucket items by day using a date extraction function + * @param items Array of items to bucket + * @param extractDate Function to extract a Date from each item + * @returns Map of YYYY-MM-DD date keys to arrays of items + */ +export function bucketByDay( + items: T[], + extractDate: (item: T) => Date +): Map { + const buckets = new Map(); + + for (const item of items) { + const date = extractDate(item); + const key = formatDateKey(date); + + if (!buckets.has(key)) { + buckets.set(key, []); + } + buckets.get(key)!.push(item); + } + + return buckets; +} + +/** + * Calculate duration in minutes between two dates + * @param start Start date + * @param end End date + * @returns Duration in minutes (rounded) + */ +export function durationInMinutes(start: Date, end: Date): number { + return Math.round((end.getTime() - start.getTime()) / (1000 * 60)); +} + +/** + * Check if a date falls within a range + * @param date Date to check + * @param range Range to check against + * @returns True if date is within range (inclusive) + */ +export function isDateInRange(date: Date, range: DateRange): boolean { + return date >= range.start && date <= range.end; +} + +/** + * Get the date N days ago at midnight + * @param days Number of days ago + * @returns Date at midnight N days ago + */ +export function daysAgo(days: number): Date { + const date = new Date(); + date.setDate(date.getDate() - days); + date.setHours(0, 0, 0, 0); + return date; +} + +/** + * Check if two dates are on the same calendar day (local timezone) + * @param date1 First date + * @param date2 Second date + * @returns True if both dates are on the same day + */ +export function isSameDay(date1: Date, date2: Date): boolean { + return formatDateKey(date1) === formatDateKey(date2); +} + +/** + * Get the start of today at midnight + * @returns Today at 00:00:00.000 + */ +export function startOfToday(): Date { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return today; +} + +/** + * Get the end of today at 23:59:59.999 + * @returns Today at 23:59:59.999 + */ +export function endOfToday(): Date { + const today = new Date(); + today.setHours(23, 59, 59, 999); + return today; +} diff --git a/homeflow/packages/healthkit/src/utils/index.ts b/homeflow/packages/healthkit/src/utils/index.ts index 6cda2ca..925c878 100644 --- a/homeflow/packages/healthkit/src/utils/index.ts +++ b/homeflow/packages/healthkit/src/utils/index.ts @@ -1 +1,16 @@ export { isExpoGo, isStandalone, getExpoGoMessage } from './expo-detection'; +export { + type DateRange, + getDateRange, + getDayBoundaries, + formatDateKey, + parseDateKey, + getDateKeysInRange, + bucketByDay, + durationInMinutes, + isDateInRange, + daysAgo, + isSameDay, + startOfToday, + endOfToday, +} from './date'; From 3566d752956fd699e045a14e38c7900ca0000660 Mon Sep 17 00:00:00 2001 From: chehanw Date: Mon, 9 Feb 2026 14:58:37 -0800 Subject: [PATCH 04/11] Reimplement HealthKit integration from scratch Delete old packages/healthkit/ workspace and rebuild as a clean service layer under lib/services/healthkit/ using @kingstinct/react-native-healthkit directly with correct Nitro module API signatures. New service layer: - types.ts: DailyActivity, SleepNight, VitalsDay, HealthPermissionResult - mappers.ts: Date helpers, sample bucketing, sleep stage mapping - HealthKitClient.ts: requestHealthPermissions(), getDailyActivity(), getSleep(), getVitals() with correct filter/limit/unit query options - index.ts: Barrel exports Data pulled: - Activity: steps, exercise/move/stand minutes, sedentary (estimated), active energy, distance - Sleep: stages (Core/Deep/REM/Awake) on iOS 16+, fallback to asleep/inBed on older iOS - Vitals: heart rate (min/avg/max), resting HR, HRV SDNN, respiratory rate, SpO2 Updated health.tsx debug panel and permissions.tsx to use new service. Removed @spezivibe/healthkit workspace reference from package.json and tsconfig.json. Co-Authored-By: Claude Opus 4.6 --- homeflow/app/(onboarding)/permissions.tsx | 82 +- homeflow/app/(tabs)/health.tsx | 325 +++----- homeflow/lib/healthkit-config.ts | 90 -- .../lib/services/healthkit/HealthKitClient.ts | 353 ++++++++ homeflow/lib/services/healthkit/index.ts | 34 + homeflow/lib/services/healthkit/mappers.ts | 165 ++++ homeflow/lib/services/healthkit/types.ts | 155 ++++ homeflow/package.json | 10 +- homeflow/packages/healthkit/README.md | 788 ------------------ homeflow/packages/healthkit/babel.config.js | 3 - homeflow/packages/healthkit/jest.setup.js | 76 -- homeflow/packages/healthkit/package.json | 81 -- .../src/__tests__/HealthKitService.test.ts | 65 -- .../src/__tests__/activity-queries.test.ts | 82 -- .../src/__tests__/date-utils.test.ts | 228 ----- .../src/__tests__/expo-detection.test.ts | 24 - .../src/__tests__/sample-types.test.ts | 67 -- .../src/__tests__/sleep-queries.test.ts | 298 ------- .../healthkit/src/__tests__/test-utils.tsx | 53 -- .../packages/healthkit/src/hooks/index.ts | 5 - .../healthkit/src/hooks/useHealthKit.ts | 78 -- .../healthkit/src/hooks/useHealthMetric.ts | 99 --- homeflow/packages/healthkit/src/index.ts | 146 ---- .../src/providers/HealthKitProvider.tsx | 138 --- .../packages/healthkit/src/providers/index.ts | 2 - .../packages/healthkit/src/sample-types.ts | 203 ----- .../src/services/HealthKitService.ts | 397 --------- .../packages/healthkit/src/services/index.ts | 25 - .../src/services/queries/activity.ts | 180 ---- .../healthkit/src/services/queries/index.ts | 33 - .../healthkit/src/services/queries/sleep.ts | 268 ------ .../healthkit/src/services/queries/vitals.ts | 257 ------ homeflow/packages/healthkit/src/types.ts | 387 --------- .../healthkit/src/ui/ExpoGoFallback.tsx | 76 -- .../packages/healthkit/src/ui/HealthView.tsx | 259 ------ .../packages/healthkit/src/ui/MetricCard.tsx | 162 ---- homeflow/packages/healthkit/src/ui/index.ts | 10 - homeflow/packages/healthkit/src/ui/theme.ts | 67 -- homeflow/packages/healthkit/src/utils/date.ts | 172 ---- .../healthkit/src/utils/expo-detection.ts | 33 - .../packages/healthkit/src/utils/index.ts | 16 - homeflow/packages/healthkit/tsconfig.json | 17 - homeflow/tsconfig.json | 2 - 43 files changed, 823 insertions(+), 5188 deletions(-) delete mode 100644 homeflow/lib/healthkit-config.ts create mode 100644 homeflow/lib/services/healthkit/HealthKitClient.ts create mode 100644 homeflow/lib/services/healthkit/index.ts create mode 100644 homeflow/lib/services/healthkit/mappers.ts create mode 100644 homeflow/lib/services/healthkit/types.ts delete mode 100644 homeflow/packages/healthkit/README.md delete mode 100644 homeflow/packages/healthkit/babel.config.js delete mode 100644 homeflow/packages/healthkit/jest.setup.js delete mode 100644 homeflow/packages/healthkit/package.json delete mode 100644 homeflow/packages/healthkit/src/__tests__/HealthKitService.test.ts delete mode 100644 homeflow/packages/healthkit/src/__tests__/activity-queries.test.ts delete mode 100644 homeflow/packages/healthkit/src/__tests__/date-utils.test.ts delete mode 100644 homeflow/packages/healthkit/src/__tests__/expo-detection.test.ts delete mode 100644 homeflow/packages/healthkit/src/__tests__/sample-types.test.ts delete mode 100644 homeflow/packages/healthkit/src/__tests__/sleep-queries.test.ts delete mode 100644 homeflow/packages/healthkit/src/__tests__/test-utils.tsx delete mode 100644 homeflow/packages/healthkit/src/hooks/index.ts delete mode 100644 homeflow/packages/healthkit/src/hooks/useHealthKit.ts delete mode 100644 homeflow/packages/healthkit/src/hooks/useHealthMetric.ts delete mode 100644 homeflow/packages/healthkit/src/index.ts delete mode 100644 homeflow/packages/healthkit/src/providers/HealthKitProvider.tsx delete mode 100644 homeflow/packages/healthkit/src/providers/index.ts delete mode 100644 homeflow/packages/healthkit/src/sample-types.ts delete mode 100644 homeflow/packages/healthkit/src/services/HealthKitService.ts delete mode 100644 homeflow/packages/healthkit/src/services/index.ts delete mode 100644 homeflow/packages/healthkit/src/services/queries/activity.ts delete mode 100644 homeflow/packages/healthkit/src/services/queries/index.ts delete mode 100644 homeflow/packages/healthkit/src/services/queries/sleep.ts delete mode 100644 homeflow/packages/healthkit/src/services/queries/vitals.ts delete mode 100644 homeflow/packages/healthkit/src/types.ts delete mode 100644 homeflow/packages/healthkit/src/ui/ExpoGoFallback.tsx delete mode 100644 homeflow/packages/healthkit/src/ui/HealthView.tsx delete mode 100644 homeflow/packages/healthkit/src/ui/MetricCard.tsx delete mode 100644 homeflow/packages/healthkit/src/ui/index.ts delete mode 100644 homeflow/packages/healthkit/src/ui/theme.ts delete mode 100644 homeflow/packages/healthkit/src/utils/date.ts delete mode 100644 homeflow/packages/healthkit/src/utils/expo-detection.ts delete mode 100644 homeflow/packages/healthkit/src/utils/index.ts delete mode 100644 homeflow/packages/healthkit/tsconfig.json diff --git a/homeflow/app/(onboarding)/permissions.tsx b/homeflow/app/(onboarding)/permissions.tsx index eb1d0c6..c2b57c0 100644 --- a/homeflow/app/(onboarding)/permissions.tsx +++ b/homeflow/app/(onboarding)/permissions.tsx @@ -22,6 +22,7 @@ import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; import { ThroneService } from '@/lib/services/throne-service'; +import { requestHealthPermissions } from '@/lib/services/healthkit'; import { OnboardingProgressBar, PermissionCard, @@ -31,17 +32,6 @@ import { } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; -// Import HealthKit conditionally -let HealthKitService: any = null; -if (Platform.OS === 'ios') { - try { - const healthkit = require('@spezivibe/healthkit'); - HealthKitService = healthkit.HealthKitService; - } catch { - // HealthKit not available - } -} - export default function PermissionsScreen() { const router = useRouter(); const colorScheme = useColorScheme(); @@ -55,24 +45,17 @@ export default function PermissionsScreen() { const canContinue = healthKitStatus === 'granted' || Platform.OS !== 'ios'; useEffect(() => { - // Check initial status + let cancelled = false; async function checkStatus() { - if (HealthKitService?.isAvailable?.()) { - // Check if we already have permission - // Note: HealthKit doesn't expose a way to check status directly - // We'll just start fresh - } - const thronePermission = await ThroneService.getPermissionStatus(); - setThroneStatus(thronePermission); + if (!cancelled) setThroneStatus(thronePermission); } - checkStatus(); + return () => { cancelled = true; }; }, []); const handleHealthKitRequest = useCallback(async () => { - if (!HealthKitService?.isAvailable?.()) { - // Not on iOS or HealthKit not available + if (Platform.OS !== 'ios') { Alert.alert( 'HealthKit Not Available', 'HealthKit is only available on iOS devices. For demo purposes, you can continue.', @@ -85,20 +68,10 @@ export default function PermissionsScreen() { setHealthKitStatus('loading'); try { - // Import sample types - const { SampleType } = require('@spezivibe/healthkit'); + const result = await requestHealthPermissions(); + setHealthKitStatus(result.success ? 'granted' : 'denied'); - const granted = await HealthKitService.requestAuthorization([ - SampleType.stepCount, - SampleType.heartRate, - SampleType.sleepAnalysis, - SampleType.activeEnergyBurned, - SampleType.distanceWalkingRunning, - ]); - - setHealthKitStatus(granted ? 'granted' : 'denied'); - - if (!granted) { + if (!result.success) { Alert.alert( 'Permission Required', 'HealthKit access is required for the study. Please enable it in Settings.', @@ -109,7 +82,6 @@ export default function PermissionsScreen() { ); } } catch (error) { - console.error('HealthKit error:', error); setHealthKitStatus('denied'); Alert.alert('Error', 'Failed to request HealthKit permissions. Please try again.'); } @@ -117,12 +89,10 @@ export default function PermissionsScreen() { const handleThroneRequest = useCallback(async () => { setThroneStatus('loading'); - try { const status = await ThroneService.requestPermission(); setThroneStatus(status); - } catch (error) { - console.error('Throne error:', error); + } catch { setThroneStatus('denied'); } }, []); @@ -134,16 +104,13 @@ export default function PermissionsScreen() { const handleContinue = async () => { setIsLoading(true); - try { - // Save permission status await OnboardingService.updateData({ permissions: { healthKit: healthKitStatus as 'granted' | 'denied' | 'not_determined', throne: throneStatus as 'granted' | 'denied' | 'not_determined' | 'skipped', }, }); - await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); router.push('/(onboarding)/medical-history' as Href); } finally { @@ -151,7 +118,6 @@ export default function PermissionsScreen() { } }; - // Dev-only handler that bypasses permission requirements const handleDevContinue = async () => { await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); router.push('/(onboarding)/medical-history' as Href); @@ -179,7 +145,6 @@ export default function PermissionsScreen() { Your data is encrypted and only used for research purposes. - {/* HealthKit Permission */} - {/* Throne Permission */} - {/* Info box */} (null); - const [activityData, setActivityData] = useState(null); - const [sleepData, setSleepData] = useState(null); - const [vitalsData, setVitalsData] = useState(null); + const [permResult, setPermResult] = useState(null); + const [activityData, setActivityData] = useState(null); + const [sleepData, setSleepData] = useState(null); + const [vitalsData, setVitalsData] = useState(null); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<'activity' | 'sleep' | 'vitals'>('activity'); + const [activeTab, setActiveTab] = useState('activity'); + + const isDark = useColorScheme() === 'dark'; const handleRequestPermissions = useCallback(async () => { setIsLoading(true); setError(null); try { - const result = await HealthKitService.requestAuthorizationWithStatus( - [...healthKitConfig.collect, ...(healthKitConfig.readOnly ?? [])], - healthKitConfig.collect - ); - setPermissionResult(result); + const result = await requestHealthPermissions(); + setPermResult(result); } catch (err) { setError(err instanceof Error ? err.message : 'Permission request failed'); } finally { @@ -61,14 +53,11 @@ function DebugPanel() { setError(null); try { const range = getDateRange(days); - - // Fetch all data types in parallel const [activity, sleep, vitals] = await Promise.all([ getDailyActivity(range), getSleep(range), getVitals(range), ]); - setActivityData(activity); setSleepData(sleep); setVitalsData(vitals); @@ -79,16 +68,13 @@ function DebugPanel() { } }, []); - const colorScheme = useColorScheme(); - const isDark = colorScheme === 'dark'; - return ( HealthKit Debug Panel - {/* Permission Section */} + {/* Permissions */} - {permissionResult && ( + {permResult && ( - Status: {permissionResult.ok ? 'Success' : 'Failed'} + Status: {permResult.success ? 'Success' : 'Failed'} - {permissionResult.granted.length > 0 && ( - - Granted: {permissionResult.granted.length} types - - )} - {permissionResult.denied.length > 0 && ( - - Denied: {permissionResult.denied.length} types - - )} - Note: Read permissions always show as undetermined for privacy + {permResult.note} )} - {/* Fetch Data Section */} + {/* Fetch buttons */} No activity data; + } return ( - {data.days.map((day) => ( + {data.map((day) => ( {day.date} @@ -215,16 +194,22 @@ function ActivityDataView({ data, isDark }: { data: DailyActivityResult; isDark: Energy: {day.activeEnergyBurned} kcal + + Distance: {Math.round(day.distanceWalkingRunning)} m + ))} ); } -function SleepDataView({ data, isDark }: { data: SleepResult; isDark: boolean }) { +function SleepDataView({ data, isDark }: { data: SleepNight[]; isDark: boolean }) { + if (data.length === 0) { + return No sleep data; + } return ( - {data.nights.map((night) => ( + {data.map((night) => ( {night.date} (night) @@ -256,7 +241,7 @@ function SleepDataView({ data, isDark }: { data: SleepResult; isDark: boolean }) ) : night.stages.asleepUndifferentiated > 0 ? ( - (Legacy data - stages not available) + (Legacy data — stages not available) ) : ( @@ -269,16 +254,19 @@ function SleepDataView({ data, isDark }: { data: SleepResult; isDark: boolean }) ); } -function VitalsDataView({ data, isDark }: { data: VitalsResult; isDark: boolean }) { +function VitalsDataView({ data, isDark }: { data: VitalsDay[]; isDark: boolean }) { + if (data.length === 0) { + return No vitals data; + } return ( - {data.days.map((day) => ( + {data.map((day) => ( {day.date} {day.heartRate.sampleCount > 0 ? ( <> - HR: {day.heartRate.min}-{day.heartRate.max} bpm (avg {day.heartRate.average}) + HR: {day.heartRate.min}–{day.heartRate.max} bpm (avg {day.heartRate.average}) Samples: {day.heartRate.sampleCount} @@ -308,58 +296,47 @@ function VitalsDataView({ data, isDark }: { data: VitalsResult; isDark: boolean } export default function HealthScreen() { - const colorScheme = useColorScheme(); - const theme = - colorScheme === 'dark' ? defaultDarkHealthTheme : defaultLightHealthTheme; - const [showDebug, setShowDebug] = useState(false); + const isDark = useColorScheme() === 'dark'; + const [showDebug, setShowDebug] = useState(true); - // HealthKit is iOS only if (Platform.OS !== 'ios') { return ( - - + + + + HealthKit is only available on iOS + + ); } return ( - } - > - - {/* Toggle Debug Panel */} - setShowDebug(!showDebug)} - > - - {showDebug ? 'Hide Debug Panel' : 'Show Debug Panel'} - - - - {showDebug && } + + setShowDebug(!showDebug)} + > + + {showDebug ? 'Hide Debug Panel' : 'Show Debug Panel'} + + - - - + {showDebug && } + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollView: { - flex: 1, - }, + container: { flex: 1, backgroundColor: '#fff' }, + containerDark: { backgroundColor: '#000' }, + centered: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, + scrollView: { flex: 1 }, debugToggle: { backgroundColor: '#007AFF', padding: 12, @@ -367,98 +344,25 @@ const styles = StyleSheet.create({ borderRadius: 8, alignItems: 'center', }, - debugToggleText: { - color: '#fff', - fontWeight: '600', - fontSize: 15, - }, - debugPanel: { - margin: 16, - marginTop: 0, - }, - debugTitle: { - fontSize: 20, - fontWeight: '700', - marginBottom: 16, - color: '#000', - }, - textDark: { - color: '#fff', - }, - textMutedDark: { - color: '#aaa', - }, - section: { - marginBottom: 16, - }, - button: { - padding: 14, - borderRadius: 10, - alignItems: 'center', - }, - primaryButton: { - backgroundColor: '#34C759', - }, - secondaryButton: { - backgroundColor: '#f0f0f0', - flex: 1, - }, - buttonText: { - color: '#fff', - fontWeight: '600', - fontSize: 16, - }, - secondaryButtonText: { - color: '#007AFF', - fontWeight: '600', - fontSize: 15, - }, - buttonRow: { - flexDirection: 'row', - gap: 12, - }, - resultBox: { - backgroundColor: '#f8f8f8', - padding: 12, - borderRadius: 8, - marginTop: 12, - }, - resultBoxDark: { - backgroundColor: '#1c1c1e', - }, - resultLabel: { - fontWeight: '600', - marginBottom: 4, - color: '#000', - }, - resultText: { - fontSize: 14, - marginBottom: 2, - }, - grantedText: { - color: '#34C759', - }, - deniedText: { - color: '#FF3B30', - }, - resultNote: { - fontSize: 12, - color: '#666', - marginTop: 8, - fontStyle: 'italic', - }, - errorBox: { - backgroundColor: '#FFE5E5', - padding: 12, - borderRadius: 8, - marginBottom: 16, - }, - errorText: { - color: '#FF3B30', - }, - loader: { - marginVertical: 16, - }, + debugToggleText: { color: '#fff', fontWeight: '600', fontSize: 15 }, + debugPanel: { margin: 16, marginTop: 0 }, + debugTitle: { fontSize: 20, fontWeight: '700', marginBottom: 16, color: '#000' }, + textDark: { color: '#fff' }, + textMutedDark: { color: '#aaa' }, + section: { marginBottom: 16 }, + button: { padding: 14, borderRadius: 10, alignItems: 'center' }, + primaryButton: { backgroundColor: '#34C759' }, + secondaryButton: { backgroundColor: '#f0f0f0', flex: 1 }, + buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 }, + secondaryButtonText: { color: '#007AFF', fontWeight: '600', fontSize: 15 }, + buttonRow: { flexDirection: 'row', gap: 12 }, + resultBox: { backgroundColor: '#f8f8f8', padding: 12, borderRadius: 8, marginTop: 12 }, + resultBoxDark: { backgroundColor: '#1c1c1e' }, + resultLabel: { fontWeight: '600', marginBottom: 4, color: '#000' }, + resultNote: { fontSize: 12, color: '#666', marginTop: 4, fontStyle: 'italic' }, + errorBox: { backgroundColor: '#FFE5E5', padding: 12, borderRadius: 8, marginBottom: 16 }, + errorText: { color: '#FF3B30' }, + loader: { marginVertical: 16 }, tabBar: { flexDirection: 'row', marginBottom: 12, @@ -466,51 +370,14 @@ const styles = StyleSheet.create({ borderRadius: 8, padding: 4, }, - tab: { - flex: 1, - paddingVertical: 8, - alignItems: 'center', - borderRadius: 6, - }, - activeTab: { - backgroundColor: '#fff', - }, - tabText: { - color: '#666', - fontWeight: '500', - }, - activeTabText: { - color: '#007AFF', - fontWeight: '600', - }, - dataContainer: { - maxHeight: 400, - }, - dayCard: { - backgroundColor: '#f8f8f8', - padding: 12, - borderRadius: 8, - marginBottom: 8, - }, - dayCardDark: { - backgroundColor: '#1c1c1e', - }, - dateText: { - fontWeight: '600', - fontSize: 15, - marginBottom: 6, - color: '#000', - }, - dataText: { - fontSize: 13, - color: '#666', - marginBottom: 2, - }, - stageLabel: { - fontSize: 13, - fontWeight: '500', - marginTop: 6, - marginBottom: 2, - color: '#000', - }, + tab: { flex: 1, paddingVertical: 8, alignItems: 'center', borderRadius: 6 }, + activeTab: { backgroundColor: '#fff' }, + tabText: { color: '#666', fontWeight: '500' }, + activeTabText: { color: '#007AFF', fontWeight: '600' }, + dataContainer: { maxHeight: 400 }, + dayCard: { backgroundColor: '#f8f8f8', padding: 12, borderRadius: 8, marginBottom: 8 }, + dayCardDark: { backgroundColor: '#1c1c1e' }, + dateText: { fontWeight: '600', fontSize: 15, marginBottom: 6, color: '#000' }, + dataText: { fontSize: 13, color: '#666', marginBottom: 2 }, + stageLabel: { fontSize: 13, fontWeight: '500', marginTop: 6, marginBottom: 2, color: '#000' }, }); diff --git a/homeflow/lib/healthkit-config.ts b/homeflow/lib/healthkit-config.ts deleted file mode 100644 index ed5c9ce..0000000 --- a/homeflow/lib/healthkit-config.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * HealthKit Configuration - * - * Configure which health data types to collect and display. - * Modify this file to customize the health metrics for your app. - * - * HomeFlow BPH Study Requirements: - * - Activity: steps, exercise time, stand time (for sedentary estimation) - * - Sleep: sleep stages (iOS 16+) with fallback to basic sleep analysis - * - Vitals: heart rate, resting HR, HRV, respiratory rate - */ - -import { HealthKitConfig, SampleType } from '@spezivibe/healthkit'; - -export const healthKitConfig: HealthKitConfig = { - // Health data types to collect (request read/write access) - // These are the core metrics for the BPH study - collect: [ - // Activity metrics - SampleType.stepCount, - SampleType.activeEnergyBurned, - SampleType.appleExerciseTime, - SampleType.appleMoveTime, - SampleType.appleStandTime, - SampleType.distanceWalkingRunning, - - // Sleep - SampleType.sleepAnalysis, - - // Vitals - SampleType.heartRate, - ], - - // Health data types to read only (no write access) - // These are recorded by Apple Watch automatically - readOnly: [ - // Body measurements (for context) - SampleType.bodyMass, - SampleType.height, - - // Advanced vitals (from Apple Watch) - SampleType.restingHeartRate, - SampleType.heartRateVariabilitySDNN, - SampleType.respiratoryRate, - SampleType.oxygenSaturation, - SampleType.walkingHeartRateAverage, - ], - - // Enable background delivery for these types (optional) - // Uncomment to enable daily background sync - // backgroundDelivery: [ - // SampleType.stepCount, - // SampleType.sleepAnalysis, - // ], - - // Whether to sync health data to the backend - // Set to true when backend integration is ready - syncToBackend: false, -}; - -/** - * Activity types used for daily activity queries - */ -export const ACTIVITY_TYPES = [ - SampleType.stepCount, - SampleType.activeEnergyBurned, - SampleType.appleExerciseTime, - SampleType.appleMoveTime, - SampleType.appleStandTime, - SampleType.distanceWalkingRunning, -] as const; - -/** - * Vitals types used for daily vitals queries - */ -export const VITALS_TYPES = [ - SampleType.heartRate, - SampleType.restingHeartRate, - SampleType.heartRateVariabilitySDNN, - SampleType.respiratoryRate, - SampleType.oxygenSaturation, -] as const; - -/** - * All types that need to be requested for full functionality - */ -export const ALL_HEALTH_TYPES = [ - ...healthKitConfig.collect, - ...(healthKitConfig.readOnly ?? []), -] as const; diff --git a/homeflow/lib/services/healthkit/HealthKitClient.ts b/homeflow/lib/services/healthkit/HealthKitClient.ts new file mode 100644 index 0000000..3065d71 --- /dev/null +++ b/homeflow/lib/services/healthkit/HealthKitClient.ts @@ -0,0 +1,353 @@ +/** + * HealthKit Client + * + * Clean abstraction over @kingstinct/react-native-healthkit. + * Exposes functions for permissions, activity, sleep, and vitals queries. + * iOS only — all functions return empty/default data on non-iOS platforms. + */ + +import { Platform } from 'react-native'; +import { + requestAuthorization, + queryQuantitySamples, + queryCategorySamples, + isHealthDataAvailable, +} from '@kingstinct/react-native-healthkit'; +import type { QuantitySample } from '@kingstinct/react-native-healthkit'; + +import { + formatDateKey, + getDateKeysInRange, + bucketSamplesByDay, + sumSamples, + statsSamples, + mapCategorySampleToSleepSample, + getSleepNightDate, + estimateSedentaryMinutes, +} from './mappers'; + +import { + SleepStage, + type DateRange, + type DailyActivity, + type SleepNight, + type VitalsDay, + type HealthPermissionResult, +} from './types'; + +// ── HK Type Identifiers ──────────────────────────────────────────── +// Using the full Apple string identifiers as required by the library. + +const HK = { + // Activity + stepCount: 'HKQuantityTypeIdentifierStepCount' as const, + activeEnergy: 'HKQuantityTypeIdentifierActiveEnergyBurned' as const, + exerciseTime: 'HKQuantityTypeIdentifierAppleExerciseTime' as const, + moveTime: 'HKQuantityTypeIdentifierAppleMoveTime' as const, + standTime: 'HKQuantityTypeIdentifierAppleStandTime' as const, + distance: 'HKQuantityTypeIdentifierDistanceWalkingRunning' as const, + + // Sleep (category type) + sleepAnalysis: 'HKCategoryTypeIdentifierSleepAnalysis' as const, + + // Vitals + heartRate: 'HKQuantityTypeIdentifierHeartRate' as const, + restingHeartRate: 'HKQuantityTypeIdentifierRestingHeartRate' as const, + hrv: 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN' as const, + respiratoryRate: 'HKQuantityTypeIdentifierRespiratoryRate' as const, + oxygenSaturation: 'HKQuantityTypeIdentifierOxygenSaturation' as const, + + // Body (read-only context) + bodyMass: 'HKQuantityTypeIdentifierBodyMass' as const, + height: 'HKQuantityTypeIdentifierHeight' as const, +}; + +/** All types we request read access for */ +const ALL_READ_TYPES = [ + HK.stepCount, + HK.activeEnergy, + HK.exerciseTime, + HK.moveTime, + HK.standTime, + HK.distance, + HK.sleepAnalysis, + HK.heartRate, + HK.restingHeartRate, + HK.hrv, + HK.respiratoryRate, + HK.oxygenSaturation, + HK.bodyMass, + HK.height, +]; + +/** Types we request write access for (subset) */ +const WRITE_TYPES = [ + HK.stepCount, + HK.activeEnergy, + HK.sleepAnalysis, + HK.heartRate, +]; + +// ── Platform guard ────────────────────────────────────────────────── + +function isIOS(): boolean { + return Platform.OS === 'ios'; +} + +// ── Query helper ──────────────────────────────────────────────────── + +async function queryQuantity( + identifier: string, + range: DateRange, + unit: string, +): Promise { + return queryQuantitySamples(identifier as any, { + limit: 0, // 0 = no limit, fetch all samples in range + unit, + filter: { + date: { + startDate: range.startDate, + endDate: range.endDate, + }, + }, + }); +} + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Request HealthKit permissions for all data types used by the app. + * Must be called before any data queries. + * + * Privacy note: HealthKit always returns "not determined" for read + * permissions regardless of whether the user granted them. This is + * an Apple privacy design — the only way to know if read was granted + * is to attempt a query and see if data comes back. + */ +export async function requestHealthPermissions(): Promise { + if (!isIOS()) { + return { + success: false, + note: 'HealthKit is only available on iOS.', + }; + } + + try { + const available = isHealthDataAvailable(); + if (!available) { + return { + success: false, + note: 'HealthKit is not available on this device.', + }; + } + + await requestAuthorization({ + toRead: ALL_READ_TYPES as any, + toShare: WRITE_TYPES as any, + }); + + return { + success: true, + note: 'Authorization requested. Read permission status is always "not determined" for privacy — this is expected Apple behavior.', + }; + } catch (error) { + return { + success: false, + note: `Permission request failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Get daily activity summaries for a date range. + * Returns one DailyActivity per day. + */ +export async function getDailyActivity(range: DateRange): Promise { + if (!isIOS()) return []; + + // Fetch all activity types in parallel + const [steps, energy, exercise, move, stand, distance] = await Promise.all([ + queryQuantity(HK.stepCount, range, 'count'), + queryQuantity(HK.activeEnergy, range, 'kcal'), + queryQuantity(HK.exerciseTime, range, 'min'), + queryQuantity(HK.moveTime, range, 'min'), + queryQuantity(HK.standTime, range, 'min'), + queryQuantity(HK.distance, range, 'm'), + ]); + + // Bucket each metric by day + const stepsByDay = bucketSamplesByDay(steps); + const energyByDay = bucketSamplesByDay(energy); + const exerciseByDay = bucketSamplesByDay(exercise); + const moveByDay = bucketSamplesByDay(move); + const standByDay = bucketSamplesByDay(stand); + const distanceByDay = bucketSamplesByDay(distance); + + // Build daily summaries + const dateKeys = getDateKeysInRange(range.startDate, range.endDate); + return dateKeys.map((date) => { + const exerciseMin = Math.round(sumSamples(exerciseByDay.get(date) ?? [])); + const moveMin = Math.round(sumSamples(moveByDay.get(date) ?? [])); + const standMin = Math.round(sumSamples(standByDay.get(date) ?? [])); + + return { + date, + steps: Math.round(sumSamples(stepsByDay.get(date) ?? [])), + exerciseMinutes: exerciseMin, + moveMinutes: moveMin, + standMinutes: standMin, + sedentaryMinutes: estimateSedentaryMinutes(exerciseMin, moveMin, standMin), + activeEnergyBurned: Math.round(sumSamples(energyByDay.get(date) ?? [])), + distanceWalkingRunning: Math.round(sumSamples(distanceByDay.get(date) ?? [])), + }; + }); +} + +/** + * Get sleep data for a date range. + * Groups sleep samples into nights with stage breakdowns. + * On iOS 16+, provides detailed Core/Deep/REM stages. + * On older iOS, falls back to "asleep" vs "in bed". + */ +export async function getSleep(range: DateRange): Promise { + if (!isIOS()) return []; + + const rawSamples = await queryCategorySamples(HK.sleepAnalysis, { + limit: 0, + filter: { + date: { + startDate: range.startDate, + endDate: range.endDate, + }, + }, + }); + + if (!rawSamples || rawSamples.length === 0) return []; + + // Convert to our SleepSample type and group by night + const nightMap = new Map[]>(); + + for (const raw of rawSamples) { + const sample = mapCategorySampleToSleepSample(raw as any); + const nightKey = getSleepNightDate(new Date(raw.startDate)); + const bucket = nightMap.get(nightKey) ?? []; + bucket.push(sample); + nightMap.set(nightKey, bucket); + } + + // Aggregate each night + const nights: SleepNight[] = []; + for (const [date, samples] of nightMap) { + let inBedMinutes = 0; + let awakeMinutes = 0; + let coreMinutes = 0; + let deepMinutes = 0; + let remMinutes = 0; + let asleepUndifferentiated = 0; + + for (const s of samples) { + switch (s.stage) { + case SleepStage.InBed: + inBedMinutes += s.durationMinutes; + break; + case SleepStage.Awake: + awakeMinutes += s.durationMinutes; + break; + case SleepStage.Core: + coreMinutes += s.durationMinutes; + break; + case SleepStage.Deep: + deepMinutes += s.durationMinutes; + break; + case SleepStage.REM: + remMinutes += s.durationMinutes; + break; + case SleepStage.AsleepUnspecified: + asleepUndifferentiated += s.durationMinutes; + break; + } + } + + const hasDetailedStages = coreMinutes > 0 || deepMinutes > 0 || remMinutes > 0; + const totalAsleep = hasDetailedStages + ? coreMinutes + deepMinutes + remMinutes + : asleepUndifferentiated; + const totalInBed = inBedMinutes > 0 + ? inBedMinutes + : totalAsleep + awakeMinutes; // fallback if no explicit inBed samples + + const efficiency = totalInBed > 0 + ? Math.round((totalAsleep / totalInBed) * 1000) / 10 + : 0; + + nights.push({ + date, + totalAsleepMinutes: Math.round(totalAsleep), + totalInBedMinutes: Math.round(totalInBed), + sleepEfficiency: efficiency, + hasDetailedStages, + stages: { + awake: Math.round(awakeMinutes), + core: Math.round(coreMinutes), + deep: Math.round(deepMinutes), + rem: Math.round(remMinutes), + asleepUndifferentiated: Math.round(asleepUndifferentiated), + }, + samples, + }); + } + + // Sort by date + nights.sort((a, b) => a.date.localeCompare(b.date)); + return nights; +} + +/** + * Get vitals data for a date range. + * Includes heart rate (min/avg/max), resting HR, HRV, respiratory rate, SpO2. + */ +export async function getVitals(range: DateRange): Promise { + if (!isIOS()) return []; + + // Fetch all vitals in parallel + const [hr, restingHR, hrvSamples, respRate, spo2] = await Promise.all([ + queryQuantity(HK.heartRate, range, 'count/min'), + queryQuantity(HK.restingHeartRate, range, 'count/min'), + queryQuantity(HK.hrv, range, 'ms'), + queryQuantity(HK.respiratoryRate, range, 'count/min'), + queryQuantity(HK.oxygenSaturation, range, '%'), + ]); + + // Bucket by day + const hrByDay = bucketSamplesByDay(hr); + const restingByDay = bucketSamplesByDay(restingHR); + const hrvByDay = bucketSamplesByDay(hrvSamples); + const respByDay = bucketSamplesByDay(respRate); + const spo2ByDay = bucketSamplesByDay(spo2); + + const dateKeys = getDateKeysInRange(range.startDate, range.endDate); + return dateKeys.map((date) => { + const hrDaySamples = hrByDay.get(date) ?? []; + const restingSamples = restingByDay.get(date) ?? []; + const hrvDaySamples = hrvByDay.get(date) ?? []; + const respSamples = respByDay.get(date) ?? []; + const spo2Samples = spo2ByDay.get(date) ?? []; + + return { + date, + heartRate: statsSamples(hrDaySamples), + restingHeartRate: restingSamples.length > 0 + ? Math.round(restingSamples[restingSamples.length - 1].quantity * 10) / 10 + : null, + hrv: hrvDaySamples.length > 0 + ? Math.round(hrvDaySamples[hrvDaySamples.length - 1].quantity * 10) / 10 + : null, + respiratoryRate: respSamples.length > 0 + ? Math.round(respSamples[respSamples.length - 1].quantity * 10) / 10 + : null, + oxygenSaturation: spo2Samples.length > 0 + ? Math.round(spo2Samples[spo2Samples.length - 1].quantity * 100 * 10) / 10 + : null, + }; + }); +} diff --git a/homeflow/lib/services/healthkit/index.ts b/homeflow/lib/services/healthkit/index.ts new file mode 100644 index 0000000..49c3b33 --- /dev/null +++ b/homeflow/lib/services/healthkit/index.ts @@ -0,0 +1,34 @@ +/** + * HealthKit Service + * + * Clean abstraction for Apple HealthKit data queries. + * Uses @kingstinct/react-native-healthkit under the hood. + * + * Usage: + * import { requestHealthPermissions, getDailyActivity, getSleep, getVitals } from '@/lib/services/healthkit'; + * + * All data collection is gated behind explicit user consent via requestHealthPermissions(). + * No health data is logged in production. Data is normalized to simple units. + */ + +export { + requestHealthPermissions, + getDailyActivity, + getSleep, + getVitals, +} from './HealthKitClient'; + +export { getDateRange } from './mappers'; + +export { SleepStage } from './types'; + +export type { + DateRange, + DailyActivity, + SleepNight, + SleepSample, + VitalsDay, + VitalsSample, + HeartRateStats, + HealthPermissionResult, +} from './types'; diff --git a/homeflow/lib/services/healthkit/mappers.ts b/homeflow/lib/services/healthkit/mappers.ts new file mode 100644 index 0000000..b375e85 --- /dev/null +++ b/homeflow/lib/services/healthkit/mappers.ts @@ -0,0 +1,165 @@ +/** + * HealthKit Mappers + * + * Maps raw HealthKit data to our normalized types. + */ + +import { CategoryValueSleepAnalysis } from '@kingstinct/react-native-healthkit'; +import type { QuantitySample } from '@kingstinct/react-native-healthkit'; +import { SleepStage, type SleepSample } from './types'; + +// ── Date helpers ──────────────────────────────────────────────────── + +/** Format a Date to YYYY-MM-DD */ +export function formatDateKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/** Get start of day (00:00:00.000) in local timezone */ +export function startOfDay(date: Date): Date { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +/** Get end of day (23:59:59.999) in local timezone */ +export function endOfDay(date: Date): Date { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +/** Build a DateRange for the last N days (including today) */ +export function getDateRange(days: number): { startDate: Date; endDate: Date } { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - (days - 1)); + return { startDate: startOfDay(start), endDate: endOfDay(end) }; +} + +/** Get all YYYY-MM-DD keys between two dates */ +export function getDateKeysInRange(startDate: Date, endDate: Date): string[] { + const keys: string[] = []; + const current = startOfDay(new Date(startDate)); + const end = startOfDay(new Date(endDate)); + while (current <= end) { + keys.push(formatDateKey(current)); + current.setDate(current.getDate() + 1); + } + return keys; +} + +// ── Quantity sample helpers ───────────────────────────────────────── + +/** Group quantity samples by YYYY-MM-DD */ +export function bucketSamplesByDay( + samples: readonly QuantitySample[], +): Map { + const map = new Map(); + for (const sample of samples) { + const key = formatDateKey(new Date(sample.startDate)); + const bucket = map.get(key) ?? []; + bucket.push(sample); + map.set(key, bucket); + } + return map; +} + +/** Sum all quantity values in a list of samples */ +export function sumSamples(samples: readonly QuantitySample[]): number { + return samples.reduce((sum, s) => sum + s.quantity, 0); +} + +/** Get min/max/avg from samples */ +export function statsSamples(samples: readonly QuantitySample[]): { + min: number; + max: number; + average: number; + sampleCount: number; +} { + if (samples.length === 0) { + return { min: 0, max: 0, average: 0, sampleCount: 0 }; + } + let min = Infinity; + let max = -Infinity; + let sum = 0; + for (const s of samples) { + if (s.quantity < min) min = s.quantity; + if (s.quantity > max) max = s.quantity; + sum += s.quantity; + } + return { + min: Math.round(min * 10) / 10, + max: Math.round(max * 10) / 10, + average: Math.round((sum / samples.length) * 10) / 10, + sampleCount: samples.length, + }; +} + +// ── Sleep mappers ─────────────────────────────────────────────────── + +/** Map HKCategoryValueSleepAnalysis to our SleepStage enum */ +export function mapSleepValue(value: number): SleepStage { + switch (value) { + case CategoryValueSleepAnalysis.inBed: + return SleepStage.InBed; + case CategoryValueSleepAnalysis.awake: + return SleepStage.Awake; + case CategoryValueSleepAnalysis.asleepCore: + return SleepStage.Core; + case CategoryValueSleepAnalysis.asleepDeep: + return SleepStage.Deep; + case CategoryValueSleepAnalysis.asleepREM: + return SleepStage.REM; + case CategoryValueSleepAnalysis.asleepUnspecified: + return SleepStage.AsleepUnspecified; + default: + return SleepStage.AsleepUnspecified; + } +} + +/** Convert a raw HK sleep category sample to our SleepSample */ +export function mapCategorySampleToSleepSample(raw: { + value: number; + startDate: Date; + endDate: Date; +}): SleepSample { + const start = new Date(raw.startDate); + const end = new Date(raw.endDate); + const durationMinutes = Math.round((end.getTime() - start.getTime()) / 60000); + return { + stage: mapSleepValue(raw.value), + startDate: start.toISOString(), + endDate: end.toISOString(), + durationMinutes: Math.max(0, durationMinutes), + }; +} + +/** + * Determine the "night date" for a sleep sample. + * Sleep that starts before 6 PM belongs to the previous night. + * Sleep that starts after 6 PM belongs to that night's date. + */ +export function getSleepNightDate(startDate: Date): string { + const d = new Date(startDate); + // If sleep started before 6 PM, it likely belongs to the previous night + if (d.getHours() < 18) { + d.setDate(d.getDate() - 1); + } + return formatDateKey(d); +} + +/** Calculate sedentary minutes estimate */ +export function estimateSedentaryMinutes( + exerciseMinutes: number, + moveMinutes: number, + standMinutes: number, +): number { + // Assume 16 waking hours = 960 minutes + const WAKING_MINUTES = 960; + const active = exerciseMinutes + moveMinutes + standMinutes; + return Math.max(0, Math.round(WAKING_MINUTES - active)); +} diff --git a/homeflow/lib/services/healthkit/types.ts b/homeflow/lib/services/healthkit/types.ts new file mode 100644 index 0000000..085cb4a --- /dev/null +++ b/homeflow/lib/services/healthkit/types.ts @@ -0,0 +1,155 @@ +/** + * HealthKit Service Types + * + * Normalized data types for health metrics from Apple HealthKit. + * All values use simple units (counts, minutes, bpm, ms, etc.) + * with ISO timestamps and timezone info. + */ + +// ── Date range for queries ────────────────────────────────────────── + +export interface DateRange { + startDate: Date; + endDate: Date; +} + +// ── Daily Activity ────────────────────────────────────────────────── + +export interface DailyActivity { + /** YYYY-MM-DD */ + date: string; + /** Total step count for the day */ + steps: number; + /** Apple Exercise minutes (vigorous activity detected by Watch) */ + exerciseMinutes: number; + /** Apple Move minutes (any movement above sedentary threshold) */ + moveMinutes: number; + /** Apple Stand minutes (minutes with at least 1 min standing per hour) */ + standMinutes: number; + /** + * Estimated sedentary minutes. + * Approximation: 960 (16h waking) - exerciseMinutes - moveMinutes - standMinutes. + * Limitation: This is a rough estimate. Apple does not expose a direct + * "sedentary time" metric. The calculation assumes ~16 waking hours and + * subtracts known active periods. Actual sedentary time may differ. + */ + sedentaryMinutes: number; + /** Active energy burned in kcal */ + activeEnergyBurned: number; + /** Walking + running distance in meters */ + distanceWalkingRunning: number; +} + +// ── Sleep ─────────────────────────────────────────────────────────── + +export enum SleepStage { + InBed = 'inBed', + Awake = 'awake', + Core = 'core', + Deep = 'deep', + REM = 'rem', + AsleepUnspecified = 'asleepUnspecified', +} + +export interface SleepSample { + stage: SleepStage; + startDate: string; // ISO 8601 + endDate: string; // ISO 8601 + durationMinutes: number; +} + +export interface SleepNight { + /** YYYY-MM-DD of the night (date sleep started) */ + date: string; + totalAsleepMinutes: number; + totalInBedMinutes: number; + /** (totalAsleep / totalInBed) * 100, rounded to 1 decimal */ + sleepEfficiency: number; + /** true if iOS 16+ detailed stage data is available */ + hasDetailedStages: boolean; + stages: { + awake: number; + core: number; + deep: number; + rem: number; + /** Fallback for older iOS without stage breakdown */ + asleepUndifferentiated: number; + }; + /** Raw samples for this night */ + samples: SleepSample[]; +} + +// ── Vitals ────────────────────────────────────────────────────────── + +export interface HeartRateStats { + min: number; // bpm + max: number; // bpm + average: number; // bpm + sampleCount: number; +} + +export interface VitalsSample { + value: number; + unit: string; + startDate: string; // ISO 8601 + endDate: string; // ISO 8601 + sourceName?: string; +} + +export interface VitalsDay { + /** YYYY-MM-DD */ + date: string; + heartRate: HeartRateStats; + /** bpm, from Apple Watch overnight analysis. null if unavailable. */ + restingHeartRate: number | null; + /** SDNN in milliseconds. null if unavailable. */ + hrv: number | null; + /** Breaths per minute. null if unavailable. */ + respiratoryRate: number | null; + /** Percentage (0-100). null if unavailable. */ + oxygenSaturation: number | null; +} + +// ── Permission result ─────────────────────────────────────────────── + +export interface HealthPermissionResult { + success: boolean; + /** HealthKit always returns "not determined" for read permissions (privacy). */ + note: string; +} + +// ── Metrics we support vs. don't ──────────────────────────────────── + +/** + * Metrics currently implemented: + * - Step count (daily total) + * - Exercise minutes (Apple Exercise Time) + * - Move minutes (Apple Move Time) + * - Stand minutes (Apple Stand Time) + * - Sedentary time (estimated) + * - Active energy burned + * - Distance walking/running + * - Sleep stages (Core/Deep/REM/Awake) with iOS 16+ fallback + * - Heart rate (min/avg/max per day) + * - Resting heart rate + * - Heart rate variability (HRV SDNN) + * - Respiratory rate + * - Oxygen saturation (SpO2) + * + * Metrics NOT currently implemented but available via HealthKit + Apple Watch: + * - VO2 Max + * - Walking heart rate average + * - Apple Walking Steadiness + * - Walking speed, step length, asymmetry + * - Stair ascent/descent speed + * - Running metrics (pace, cadence, ground contact time, power) + * - Cycling metrics (speed, power, cadence) + * - Blood pressure + * - Body temperature / wrist temperature + * - Blood glucose + * - Electrocardiogram (ECG) + * - Environmental/headphone audio exposure + * - Time in daylight + * - Workouts (detailed workout sessions) + * - Mindfulness sessions + */ diff --git a/homeflow/package.json b/homeflow/package.json index d89aff2..1bc197a 100644 --- a/homeflow/package.json +++ b/homeflow/package.json @@ -4,8 +4,8 @@ "version": "1.0.0", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", "typecheck": "tsc --noEmit", @@ -19,13 +19,13 @@ "@ai-sdk/openai": "^3.0.1", "@blazejkustra/react-native-alert": "^1.0.0", "@expo/vector-icons": "^15.0.3", - "@kingstinct/react-native-healthkit": "^9.0.0", + "@kingstinct/react-native-healthkit": "^13.1.1", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.9.0", "@react-navigation/elements": "^2.9.3", "@react-navigation/native": "^7.1.26", "@spezivibe/chat": "*", - "@spezivibe/healthkit": "*", + "@spezivibe/questionnaire": "*", "@spezivibe/scheduler": "*", "ai": "^6.0.3", @@ -46,7 +46,7 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-nitro-modules": "^0.24.0", + "react-native-nitro-modules": "^0.33.7", "react-native-reanimated": "~4.1.6", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/homeflow/packages/healthkit/README.md b/homeflow/packages/healthkit/README.md deleted file mode 100644 index d320a5b..0000000 --- a/homeflow/packages/healthkit/README.md +++ /dev/null @@ -1,788 +0,0 @@ -# @spezivibe/healthkit - -A React Native package for Apple HealthKit integration in Expo apps. Provides configurable health data collection, React hooks, and pre-built UI components. Built on [@kingstinct/react-native-healthkit](https://github.com/kingstinct/react-native-healthkit). - -## Table of Contents - -- [Features](#features) -- [Requirements](#requirements) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Configuration](#configuration) -- [Components](#components) -- [Hooks](#hooks) -- [Theming](#theming) -- [API Reference](#api-reference) -- [Sample Types](#sample-types) -- [Platform Support](#platform-support) -- [Examples](#examples) -- [Troubleshooting](#troubleshooting) - -## Features - -- **50+ Health Metrics** - Steps, heart rate, sleep, blood glucose, and more -- **Configurable Collection** - Declarative API to specify which data to collect -- **React Hooks** - `useHealthKit` and `useHealthMetric` for easy data access -- **Pre-built UI** - Ready-to-use `HealthView` dashboard and `MetricCard` components -- **Expo Go Detection** - Automatic fallback UI when running in Expo Go -- **TypeScript** - Full TypeScript support with exported types -- **Theming** - Customizable theme system - -## Requirements - -> **Important:** HealthKit requires a **custom development build**. It will NOT work in Expo Go. - -- **iOS only** - HealthKit is an Apple-only framework -- **Expo SDK 52+** - Uses native modules -- **Custom dev client** - Requires `npx expo run:ios` or EAS Build -- **Physical device recommended** - Simulator has limited HealthKit support - -## Installation - -```bash -npm install @spezivibe/healthkit @kingstinct/react-native-healthkit react-native-nitro-modules -``` - -### Peer Dependencies - -```json -{ - "react": ">=18.0.0", - "react-native": ">=0.70.0", - "expo": ">=52.0.0", - "expo-constants": ">=17.0.0" -} -``` - -### Expo Configuration - -Add the HealthKit plugin to your `app.config.js` or `app.json`: - -```javascript -// app.config.js -export default { - expo: { - // ... other config - plugins: [ - [ - "@kingstinct/react-native-healthkit", - { - NSHealthShareUsageDescription: "This app needs access to your health data to display your fitness metrics.", - NSHealthUpdateUsageDescription: "This app needs permission to save health data." - } - ] - ] - } -}; -``` - -### Building for iOS - -Since HealthKit requires native code, you must create a custom development build: - -```bash -# Create native iOS project and run on device/simulator -npx expo prebuild --platform ios -npx expo run:ios - -# Or use EAS Build for cloud builds -eas build --platform ios --profile development -``` - -## Quick Start - -### 1. Create Configuration - -Create a configuration file specifying which health data to collect: - -```typescript -// lib/healthkit-config.ts -import { HealthKitConfig, SampleType } from '@spezivibe/healthkit'; - -export const healthKitConfig: HealthKitConfig = { - // Health data to actively collect and display - collect: [ - SampleType.stepCount, - SampleType.heartRate, - SampleType.activeEnergyBurned, - SampleType.sleepAnalysis, - ], - // Read-only access (request permission but don't display by default) - readOnly: [], - // Enable background delivery for specific types - backgroundDelivery: [], - // Optional: sync to backend (requires backend integration) - syncToBackend: false, -}; -``` - -### 2. Add Provider - -Wrap your app (or relevant screens) with `HealthKitProvider`: - -```tsx -// App.tsx or _layout.tsx -import { HealthKitProvider } from '@spezivibe/healthkit'; -import { healthKitConfig } from './lib/healthkit-config'; - -export default function RootLayout() { - return ( - - {/* Your app content */} - - ); -} -``` - -### 3. Display Health Data - -Use the pre-built `HealthView` component: - -```tsx -// app/(tabs)/health.tsx -import { HealthView } from '@spezivibe/healthkit'; -import { healthKitConfig } from '../lib/healthkit-config'; - -export default function HealthScreen() { - return ; -} -``` - -That's it! The `HealthView` component handles authorization prompts, displays configured metrics, and shows a fallback UI if running in Expo Go. - -## Configuration - -### HealthKitConfig - -The configuration object controls which health data your app collects: - -```typescript -interface HealthKitConfig { - /** Health data types to collect and display */ - collect: SampleType[]; - - /** Types with read-only access (permission requested but not displayed) */ - readOnly: SampleType[]; - - /** Types to receive background delivery updates */ - backgroundDelivery: SampleType[]; - - /** Whether to sync health data to backend */ - syncToBackend: boolean; -} -``` - -### Example Configurations - -#### Fitness App - -```typescript -const fitnessConfig: HealthKitConfig = { - collect: [ - SampleType.stepCount, - SampleType.activeEnergyBurned, - SampleType.distanceWalkingRunning, - SampleType.flightsClimbed, - SampleType.workoutType, - ], - readOnly: [], - backgroundDelivery: [SampleType.stepCount], - syncToBackend: false, -}; -``` - -#### Health Monitoring App - -```typescript -const healthMonitorConfig: HealthKitConfig = { - collect: [ - SampleType.heartRate, - SampleType.bloodPressureSystolic, - SampleType.bloodPressureDiastolic, - SampleType.oxygenSaturation, - SampleType.respiratoryRate, - ], - readOnly: [SampleType.bodyMass, SampleType.height], - backgroundDelivery: [SampleType.heartRate], - syncToBackend: true, -}; -``` - -#### Diabetes Management App - -```typescript -const diabetesConfig: HealthKitConfig = { - collect: [ - SampleType.bloodGlucose, - SampleType.insulinDelivery, - SampleType.dietaryCarbohydrates, - ], - readOnly: [], - backgroundDelivery: [SampleType.bloodGlucose], - syncToBackend: true, -}; -``` - -## Components - -### HealthView - -The main dashboard component that displays all configured health metrics. - -```tsx -import { HealthView } from '@spezivibe/healthkit'; - - console.log('Auth:', success)} // Optional -/> -``` - -**Features:** -- Handles HealthKit authorization prompt -- Displays metric cards for all configured types -- Shows Expo Go fallback automatically -- Supports pull-to-refresh - -### MetricCard - -Individual metric display card. - -```tsx -import { MetricCard } from '@spezivibe/healthkit'; -import { SampleType } from '@spezivibe/healthkit'; - - console.log('tapped')} // Optional: tap handler -/> -``` - -### ExpoGoFallback - -Fallback UI shown when running in Expo Go. - -```tsx -import { ExpoGoFallback } from '@spezivibe/healthkit'; - - -``` - -Or provide a custom fallback to `HealthKitProvider`: - -```tsx -} -> - {children} - -``` - -## Hooks - -### useHealthKit - -Main hook for HealthKit operations. - -```tsx -import { useHealthKit } from '@spezivibe/healthkit'; - -function MyComponent() { - const { - isAvailable, // boolean: HealthKit available on this device - isAuthorized, // boolean: user has granted permission - isLoading, // boolean: authorization in progress - error, // Error | null: any error that occurred - requestAuthorization, // () => Promise: request permission - getTodayValue, // (type: SampleType) => Promise - getMostRecent, // (type: SampleType) => Promise - } = useHealthKit(); - - const handlePress = async () => { - const success = await requestAuthorization(); - if (success) { - const steps = await getTodayValue(SampleType.stepCount); - console.log('Today steps:', steps); - } - }; - - if (!isAvailable) { - return HealthKit not available; - } - - return ( -