From b2a0f650093c6cd68c68b0236142aff1f9b85179 Mon Sep 17 00:00:00 2001 From: chehanw Date: Wed, 4 Mar 2026 13:10:05 -0800 Subject: [PATCH 1/8] =?UTF-8?q?Add=20HRV=20and=20sleep=20data=20to=20Healt?= =?UTF-8?q?hKit=20=E2=86=92=20Firestore=20sync=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HRV (Heart Rate Variability SDNN): - Added heartRateVariabilitySDNN to METRIC_CONFIG with unit "ms" - Reuses the existing quantity sample pipeline with no additional code Sleep: - New syncSleep() using queryCategorySamples on HKCategoryTypeIdentifierSleepAnalysis - Firestore path: users/{uid}/healthkit/sleepAnalysis/samples/{uuid} - Sync state at: users/{uid}/healthkitSync/sleepAnalysis - Each doc stores: value (raw enum), stage (readable string e.g. "asleepCore", "asleepDeep", "asleepREM"), startDate, endDate, durationMinutes, sourceName?, deviceName? - Incremental sync with same 30-day lookback / overlap strategy as quantity metrics Both pipelines run in parallel in bootstrapHealthKitSync(). Co-Authored-By: Claude Sonnet 4.6 --- homeflow/package-lock.json | 3 - homeflow/src/services/healthkitSync.ts | 133 ++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/homeflow/package-lock.json b/homeflow/package-lock.json index 296a937..d63a251 100644 --- a/homeflow/package-lock.json +++ b/homeflow/package-lock.json @@ -2286,7 +2286,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -14533,7 +14532,6 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", "license": "MIT", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", "semver": "7.7.2" @@ -14639,7 +14637,6 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz", "integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==", "license": "MIT", - "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", diff --git a/homeflow/src/services/healthkitSync.ts b/homeflow/src/services/healthkitSync.ts index 73c3a74..d2c8fb7 100644 --- a/homeflow/src/services/healthkitSync.ts +++ b/homeflow/src/services/healthkitSync.ts @@ -38,6 +38,8 @@ import { import type { FieldValue } from "firebase/firestore"; import { queryQuantitySamples, + queryCategorySamples, + CategoryValueSleepAnalysis, } from "@kingstinct/react-native-healthkit"; import type { QuantitySample } from "@kingstinct/react-native-healthkit"; @@ -56,6 +58,10 @@ const METRIC_CONFIG = { identifier: "HKQuantityTypeIdentifierStepCount" as const, unit: "count", }, + heartRateVariabilitySDNN: { + identifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN" as const, + unit: "ms", + }, } as const; export type MetricType = keyof typeof METRIC_CONFIG; @@ -388,6 +394,122 @@ export async function syncAllHealthKit(): Promise { return { ok: allOk, results }; } +// ── Sleep sync ──────────────────────────────────────────────────────────────── + +const SLEEP_STAGE_LABEL: Record = { + [CategoryValueSleepAnalysis.inBed]: "inBed", + [CategoryValueSleepAnalysis.asleepUnspecified]: "asleepUnspecified", + [CategoryValueSleepAnalysis.awake]: "awake", + [CategoryValueSleepAnalysis.asleepCore]: "asleepCore", + [CategoryValueSleepAnalysis.asleepDeep]: "asleepDeep", + [CategoryValueSleepAnalysis.asleepREM]: "asleepREM", +}; + +export interface SyncSleepResult { + ok: boolean; + written: number; + error?: string; +} + +/** + * Syncs HKCategoryTypeIdentifierSleepAnalysis samples from HealthKit to + * Firestore for the signed-in user. + * + * Firestore path: users/{uid}/healthkit/sleepAnalysis/samples/{uuid} + * Sync state: users/{uid}/healthkitSync/sleepAnalysis + * + * Each document stores: value (raw enum int), stage (readable string), + * startDate, endDate, durationMinutes, sourceName?, deviceName?. + */ +export async function syncSleep(): Promise { + if (Platform.OS !== "ios") return { ok: true, written: 0 }; + + const uid = getAuth().currentUser?.uid; + if (!uid) return { ok: false, written: 0, error: "no-auth" }; + + const syncStateRef = doc(db, `users/${uid}/healthkitSync/sleepAnalysis`); + + try { + // 1. Determine incremental start date. + const stateSnap = await getDoc(syncStateRef); + const lastSyncedAt: Date | null = stateSnap.exists() + ? (stateSnap.data() as { lastSyncedAt?: Timestamp }).lastSyncedAt?.toDate() ?? null + : null; + + const sinceDate = lastSyncedAt + ? new Date(lastSyncedAt.getTime() - OVERLAP_WINDOW_MS) + : new Date(Date.now() - DEFAULT_LOOKBACK_DAYS * 24 * 60 * 60 * 1_000); + + const endDate = new Date(); + + // 2. Query HealthKit for sleep category samples. + const samples = await queryCategorySamples( + "HKCategoryTypeIdentifierSleepAnalysis", + { limit: 0, filter: { date: { startDate: sinceDate, endDate } } }, + ); + + if (samples.length === 0) { + await setDoc(syncStateRef, { lastRunAt: serverTimestamp(), lastStatus: "ok" }, { merge: true }); + return { ok: true, written: 0 }; + } + + // 3. Write in batches. + const toDate = (d: unknown): Date => + d instanceof Date ? d : new Date(String(d)); + + let maxEndDate = new Date(0); + + for (let i = 0; i < samples.length; i += BATCH_SIZE) { + const chunk = samples.slice(i, i + BATCH_SIZE); + const batch = writeBatch(db); + + for (const s of chunk) { + const start = toDate(s.startDate); + const end = toDate(s.endDate); + if (end > maxEndDate) maxEndDate = end; + + const data: Record = { + value: s.value, + stage: SLEEP_STAGE_LABEL[s.value as CategoryValueSleepAnalysis] ?? "unknown", + startDate: Timestamp.fromDate(start), + endDate: Timestamp.fromDate(end), + durationMinutes: Math.round((end.getTime() - start.getTime()) / 60_000), + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }; + + const sourceName = s.sourceRevision?.source?.name; + if (sourceName) data.sourceName = sourceName; + const deviceName = s.device?.name; + if (deviceName) data.deviceName = deviceName; + + const ref = doc(db, `users/${uid}/healthkit/sleepAnalysis/samples/${s.uuid}`); + batch.set(ref, data); + } + + await batch.commit(); + console.log(`[HealthKit] Sleep batch committed (${chunk.length} docs)`); + } + + // 4. Advance sync state. + await setDoc(syncStateRef, { + lastSyncedAt: Timestamp.fromDate(maxEndDate), + lastRunAt: serverTimestamp(), + lastStatus: "ok", + }, { merge: true }); + + return { ok: true, written: samples.length }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await setDoc(syncStateRef, { + lastRunAt: serverTimestamp(), + lastStatus: "error", + lastError: message, + }, { merge: true }).catch(() => {}); + return { ok: false, written: 0, error: message }; + } +} + // ── bootstrapHealthKitSync ──────────────────────────────────────────────────── /** @@ -398,9 +520,10 @@ export async function syncAllHealthKit(): Promise { export async function bootstrapHealthKitSync(): Promise { console.log("[HealthKit] bootstrapHealthKitSync: starting"); try { - // All three pipelines use separate HealthKit APIs — run in parallel. - const [hkResult, clinicalResult, fhirResult] = await Promise.all([ + // All pipelines use separate HealthKit APIs — run in parallel. + const [hkResult, sleepResult, clinicalResult, fhirResult] = await Promise.all([ syncAllHealthKit(), + syncSleep(), syncClinicalNotes(), syncFhirPrefill(), ]); @@ -411,6 +534,12 @@ export async function bootstrapHealthKitSync(): Promise { console.warn("[HealthKit] bootstrapHealthKitSync: quantity metrics had errors", hkResult.results); } + if (sleepResult.ok) { + console.log(`[HealthKit] bootstrapHealthKitSync: sleep synced OK — written: ${sleepResult.written}`); + } else { + console.warn("[HealthKit] bootstrapHealthKitSync: sleep sync error:", sleepResult.error); + } + if (clinicalResult.ok) { console.log( `[HealthKit] bootstrapHealthKitSync: clinical notes synced OK — uploaded: ${clinicalResult.uploaded}, skipped: ${clinicalResult.skipped}`, From 5ce45813c7aa72e18f7704ae4bc2a27a8179293e Mon Sep 17 00:00:00 2001 From: James Rhee Date: Mon, 9 Mar 2026 17:37:59 -0700 Subject: [PATCH 2/8] Redesign home screen with Throne uroflow and HealthKit wellness dashboard - Throne Science module: metric toggles (Avg Flow/Volume/Duration/Max Flow), 7-day bar chart via bucketSeries, real synced data from fetchSessions+fetchMetricsBatch - Apple HealthKit module: three concentric activity rings (Move/Exercise/Steps), sleep quality, heart rate, SpO2, respiratory rate via useHealthSummary() - Preserved all existing functionality (surgery date, study timeline, watch reminder, dev tools modal, SurgeryCompleteModal, pull-to-refresh) - Fixed data fetch to match voiding.tsx pattern (no userId filter, no status filter) Co-Authored-By: Claude Sonnet 4.6 --- homeflow/app/(tabs)/index.tsx | 956 ++++++++++++++++++++++++++-------- 1 file changed, 745 insertions(+), 211 deletions(-) diff --git a/homeflow/app/(tabs)/index.tsx b/homeflow/app/(tabs)/index.tsx index 1fb63b5..0ae6f5a 100644 --- a/homeflow/app/(tabs)/index.tsx +++ b/homeflow/app/(tabs)/index.tsx @@ -1,13 +1,27 @@ -import React, { useState } from 'react'; +/** + * Home Screen — StreamSync Wellness Dashboard + * + * Displays: + * - Throne Science uroflow module (7-day chart with metric toggle) + * - Apple HealthKit module (activity rings, sleep, vitals) + * - Surgery date & study timeline + * - Watch wear reminder + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { + ActivityIndicator, + Dimensions, + Modal, + Platform, + Pressable, + RefreshControl, + ScrollView, StyleSheet, - View, Text, - ScrollView, TouchableOpacity, + View, Alert, - Modal, - Pressable, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; @@ -18,15 +32,495 @@ import { useSurgeryDate } from '@/hooks/use-surgery-date'; import { useWatchUsage } from '@/hooks/use-watch-usage'; import { SurgeryCompleteModal } from '@/components/home/SurgeryCompleteModal'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { useAuth } from '@/lib/auth/auth-context'; +import { useHealthSummary } from '@/hooks/use-health-summary'; +import { + fetchSessions, + fetchMetricsBatch, + type ThroneSession, + type ThroneMetric, +} from '@/src/services/throneFirestore'; +import { + parseSessionWithMetrics, + type ParsedVoidSession, +} from '@/src/data/parseVoidingSession'; +import { + filterByRange, + bucketSeries, + computeSummaryStats, + type BucketPoint, +} from '@/src/data/voidingAggregation'; +import { + METRIC_LABELS, + METRIC_UNITS, + METRIC_KEYS, + type VoidMetricKey, +} from '@/src/data/voidingFieldMap'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +const MOVE_GOAL_KCAL = 500; +const EXERCISE_GOAL_MIN = 30; +const STEPS_GOAL = 10_000; + +// ─── Activity Ring Component ────────────────────────────────────────────────── + +/** + * Draws a single circular progress ring using the two-half-disc technique. + * Works without any SVG library. + */ +function CircleRing({ + pct, + color, + size, + strokeWidth, + bgColor, +}: { + pct: number; + color: string; + size: number; + strokeWidth: number; + bgColor: string; +}) { + const r = Math.max(0, Math.min(1, isNaN(pct) ? 0 : pct)); + const half = size / 2; + const innerSize = size - strokeWidth * 2; + + const rightDeg = r <= 0.5 ? -180 + r * 360 : 0; + const leftDeg = r <= 0.5 ? -180 : -180 + (r - 0.5) * 360; + + return ( + + {/* Gray track disc */} + + + {/* Right half fill */} + + + + + {/* Left half fill — only shown past 50% */} + {r > 0.5 && ( + + + + )} + + {/* Inner cutout to create ring appearance */} + + + ); +} + +/** Three concentric activity rings (Move / Exercise / Steps). */ +function ActivityRings({ + movePct, + exercisePct, + stepsPct, + cardColor, +}: { + movePct: number; + exercisePct: number; + stepsPct: number; + cardColor: string; +}) { + const STROKE = 10; + const GAP = 3; + const OUTER = 86; + const MIDDLE = OUTER - STROKE * 2 - GAP * 2; + const INNER = MIDDLE - STROKE * 2 - GAP * 2; + + const outerCenter = OUTER / 2; + const middleOffset = STROKE + GAP; + const innerOffset = STROKE * 2 + GAP * 2; + + return ( + + + + + + + + + + ); +} + +// ─── Mini bar chart ─────────────────────────────────────────────────────────── + +function MiniBarChart({ + data, + accentColor, + c, +}: { + data: BucketPoint[]; + accentColor: string; + c: ReturnType['theme']['colors']; +}) { + const CHART_HEIGHT = 80; + const screenW = Dimensions.get('window').width; + const chartW = screenW - 32 * 2 - 16 * 2 - 36; // screen - screenPad*2 - cardPad*2 - yAxis + + if (data.length === 0) { + return ( + + + No data this week + + + ); + } + + const maxVal = Math.max(...data.map((d) => d.value), 0.01); + const barW = Math.max(6, Math.floor((chartW - (data.length - 1) * 4) / data.length)); + + return ( + + + {/* Y-axis */} + + + {maxVal.toFixed(1)} + + 0 + + + {/* Bars */} + + + + + + {data.map((pt, i) => ( + + + + ))} + + + + + {/* X labels */} + + {data.map((pt, i) => { + const show = + data.length <= 7 || + i === 0 || + i === data.length - 1 || + i % Math.ceil(data.length / 5) === 0; + return ( + + {show && ( + + {pt.label} + + )} + + ); + })} + + + ); +} + +const miniChartStyles = StyleSheet.create({ + chartRow: { flexDirection: 'row', alignItems: 'stretch' }, + yAxis: { width: 36, justifyContent: 'space-between', paddingRight: 6, alignItems: 'flex-end' }, + axisLabel: { fontSize: 9 }, + barArea: { flex: 1, position: 'relative' }, + gridLine: { + position: 'absolute', + left: 0, + right: 0, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + barRow: { flex: 1, flexDirection: 'row', alignItems: 'flex-end', gap: 4 }, + barWrapper: { flex: 1, alignItems: 'center', justifyContent: 'flex-end', height: '100%' }, + xRow: { flexDirection: 'row', marginTop: 4 }, + xLabel: { fontSize: 9 }, + empty: { justifyContent: 'center', alignItems: 'center' }, + emptyText: { fontSize: 13 }, +}); + +// ─── Metric pill row ────────────────────────────────────────────────────────── + +function MetricPills({ + selected, + onSelect, + c, +}: { + selected: VoidMetricKey; + onSelect: (k: VoidMetricKey) => void; + c: ReturnType['theme']['colors']; +}) { + return ( + + {METRIC_KEYS.map((key) => { + const active = key === selected; + return ( + onSelect(key)} + style={[ + pillStyles.pill, + { backgroundColor: active ? c.accent : c.secondaryFill }, + ]} + > + + {METRIC_LABELS[key]} + + + ); + })} + + ); +} + +const pillStyles = StyleSheet.create({ + row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + pill: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 20 }, + text: { fontSize: 12, fontWeight: '600' }, +}); + +// ─── Vitals row ─────────────────────────────────────────────────────────────── + +function VitalRow({ + icon, + label, + value, + color, + c, + isLast, +}: { + icon: string; + label: string; + value: string; + color: string; + c: ReturnType['theme']['colors']; + isLast?: boolean; +}) { + return ( + <> + + + + + {label} + {value} + + {!isLast && } + + ); +} + +const vitalStyles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: 10 }, + iconBox: { width: 30, height: 30, borderRadius: 8, alignItems: 'center', justifyContent: 'center' }, + label: { flex: 1, fontSize: 15 }, + value: { fontSize: 15, fontWeight: '600' }, + divider: { height: StyleSheet.hairlineWidth, marginLeft: 40 }, +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatMetricSummary(sessions: ParsedVoidSession[], metric: VoidMetricKey): string { + const vals = sessions.map((s) => s[metric]).filter((v): v is number => v !== null); + if (vals.length === 0) return '—'; + const avg = vals.reduce((a, b) => a + b, 0) / vals.length; + if (metric === 'durationSeconds') { + const sec = Math.round(avg); + if (sec >= 60) return `${Math.floor(sec / 60)}m ${sec % 60}s`; + return `${sec}s`; + } + return avg.toFixed(1); +} + +function daysUntil(dateStr: string): string { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(dateStr + 'T00:00:00'); + const diff = Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + if (diff === 0) return 'Scheduled for today'; + if (diff === 1) return 'Tomorrow'; + if (diff < 0) return ''; + return `${diff} days from now`; +} + +// ─── Main Screen ────────────────────────────────────────────────────────────── export default function HomeScreen() { const { theme } = useAppTheme(); const { isDark, colors: t } = theme; + const { user } = useAuth(); const surgery = useSurgeryDate(); const watch = useWatchUsage(); + const [showSurgeryModal, setShowSurgeryModal] = useState(false); const [showDevSheet, setShowDevSheet] = useState(false); const [watchDismissed, setWatchDismissed] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + // ─── Throne data ─────────────────────────────────────────────────────────── + + const [sessions, setSessions] = useState([]); + const [throneLoading, setThroneLoading] = useState(true); + const [selectedMetric, setSelectedMetric] = useState('avgFlowRate'); + + useEffect(() => { + let cancelled = false; + + async function loadThroneData() { + setThroneLoading(true); + try { + const since = new Date(Date.now() - WEEK_MS); + // Match voiding.tsx: no userId filter (Firestore userId field may differ from auth uid) + const raw: ThroneSession[] = await fetchSessions({ startDate: since }); + if (cancelled) return; + + const ids = raw.map((s) => s.id); + const allMetrics: ThroneMetric[] = await fetchMetricsBatch(ids); + if (cancelled) return; + + // Build sessionId → ThroneMetric[] map (same as voiding.tsx) + const metricsMap = new Map(); + for (const m of allMetrics) { + const arr = metricsMap.get(m.sessionId); + if (arr) arr.push(m); + else metricsMap.set(m.sessionId, [m]); + } + + // Keep all sessions (no status filter) — same as voiding.tsx + const parsed: ParsedVoidSession[] = raw + .map((s) => parseSessionWithMetrics(s, metricsMap.get(s.id) ?? [])); + + if (!cancelled) setSessions(parsed); + } catch { + // silent — empty state shows + } finally { + if (!cancelled) setThroneLoading(false); + } + } + + loadThroneData(); + return () => { cancelled = true; }; + }, [refreshKey]); + + const weekSessions = useMemo( + () => filterByRange(sessions, '1w'), + [sessions], + ); + + const chartData = useMemo( + () => bucketSeries(weekSessions, '1w', selectedMetric), + [weekSessions, selectedMetric], + ); + + const metricAvg = useMemo( + () => formatMetricSummary(weekSessions, selectedMetric), + [weekSessions, selectedMetric], + ); + + // ─── HealthKit data ──────────────────────────────────────────────────────── + + const { summary: health, isLoading: healthLoading, refresh: refreshHealth } = useHealthSummary(); + + const activity = health?.activity ?? null; + const sleep = health?.raw.sleep ?? null; + const vitals = health?.raw.vitals ?? null; + + const movePct = activity ? Math.min(1, activity.energyBurned / MOVE_GOAL_KCAL) : 0; + const exercisePct = activity ? Math.min(1, activity.activeMinutes / EXERCISE_GOAL_MIN) : 0; + const stepsPct = activity ? Math.min(1, activity.steps / STEPS_GOAL) : 0; + + const sleepLabel = sleep + ? `${Math.floor(sleep.totalAsleepMinutes / 60)}h ${sleep.totalAsleepMinutes % 60}m` + : '—'; + + const hrLabel = vitals?.heartRate?.average + ? `${Math.round(vitals.heartRate.average)} bpm` + : vitals?.restingHeartRate + ? `${Math.round(vitals.restingHeartRate)} bpm` + : '—'; + + const spo2Label = vitals?.oxygenSaturation + ? `${Math.round(vitals.oxygenSaturation * 100)}%` + : '—'; + + const respLabel = vitals?.respiratoryRate + ? `${Math.round(vitals.respiratoryRate)} brpm` + : '—'; + + // ─── Refresh ─────────────────────────────────────────────────────────────── + + const onRefresh = useCallback(() => { + setRefreshKey((k) => k + 1); + refreshHealth(); + }, [refreshHealth]); const handleResetOnboarding = () => { Alert.alert( @@ -41,8 +535,7 @@ export default function HomeScreen() { try { await OnboardingService.reset(); notifyOnboardingComplete(); - } catch (error) { - console.error('Error resetting onboarding:', error); + } catch { Alert.alert('Error', 'Failed to reset onboarding'); } }, @@ -53,14 +546,23 @@ export default function HomeScreen() { const showWatchReminder = !watch.isLoading && !watch.watchWornRecently && !watchDismissed; + // ─── Render ──────────────────────────────────────────────────────────────── + return ( + } > - {/* Header — Apple Health style: date above, large title below */} + {/* Header */} @@ -89,13 +591,159 @@ export default function HomeScreen() { )} - {/* Surgery Date Card */} + {/* ─── Throne Science Module ─────────────────────────────────────── */} + + + + + + + + + Throne Science + + + Uroflow · Past 7 days + + + + + + {/* Metric toggles */} + + + {/* Summary stat */} + + + {throneLoading ? '—' : metricAvg} + + + {' '}{METRIC_UNITS[selectedMetric]} + + + {' '}avg {METRIC_LABELS[selectedMetric].toLowerCase()} + + + + {/* Bar chart */} + {throneLoading ? ( + + + + ) : ( + + )} + + + {/* ─── Apple HealthKit Module ────────────────────────────────────── */} + + + + + + + + + Apple Health + + + Today's summary + + + + + + {healthLoading ? ( + + + + ) : Platform.OS !== 'ios' ? ( + + Apple Health is only available on iOS. + + ) : ( + <> + {/* Activity rings + stats */} + + + + + + + + Move + + + {activity ? `${Math.round(activity.energyBurned)} kcal` : '—'} + + + + + + Exercise + + + {activity ? `${activity.activeMinutes} min` : '—'} + + + + + + Steps + + + {activity ? activity.steps.toLocaleString() : '—'} + + + + + + + + {/* Sleep, Heart Rate, SpO2, Respiratory */} + + + + + + )} + + + {/* ─── Surgery Date Card ─────────────────────────────────────────── */} - - Surgery date - + Surgery date {surgery.isPlaceholder && __DEV__ && ( @@ -125,14 +773,12 @@ export default function HomeScreen() { )} - {/* Study Timeline Card */} + {/* Study Timeline */} {!surgery.isLoading && ( - - Study timeline - + Study timeline {surgery.isPlaceholder && __DEV__ && ( @@ -143,18 +789,14 @@ export default function HomeScreen() { - - Start - + Start {surgery.studyStartLabel} - - End - + End {surgery.studyEndLabel} @@ -163,7 +805,7 @@ export default function HomeScreen() { )} - {/* Watch Reminder */} + {/* Watch reminder */} {showWatchReminder && ( @@ -196,7 +838,7 @@ export default function HomeScreen() { )} - {/* Recovery Instructions card — shown after surgery */} + {/* Recovery card */} {surgery.hasPassed && ( Recovery plan - + - - Your Recovery Plan - + Your Recovery Plan Stanford discharge instructions · tap to review )} - {/* Study info */} - - - Your Study - - - Track your BPH symptoms, voiding patterns, and recovery progress - throughout the study. Your daily data helps your care team - understand your health patterns. - - - @@ -252,9 +885,7 @@ export default function HomeScreen() { onPress={() => {}} > - - Developer Tools - + Developer Tools setShowDevSheet(false)} activeOpacity={0.7} > - - Close - + Close @@ -301,57 +930,24 @@ export default function HomeScreen() { ); } -function daysUntil(dateStr: string): string { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const target = new Date(dateStr + 'T00:00:00'); - const diff = Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); - if (diff === 0) return 'Scheduled for today'; - if (diff === 1) return 'Tomorrow'; - if (diff < 0) return ''; - return `${diff} days from now`; -} +// ─── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingHorizontal: 16, - paddingTop: 8, - }, + container: { flex: 1 }, + scrollView: { flex: 1 }, + scrollContent: { paddingHorizontal: 16, paddingTop: 8 }, - // Header — matches Apple Health large-title pattern + // Header headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20, }, - headerText: { - flex: 1, - }, - dateLabel: { - fontSize: 13, - fontWeight: '400', - marginBottom: 2, - }, - greetingNormal: { - fontSize: 34, - fontWeight: '700', - letterSpacing: 0.37, - }, - greeting: { - fontSize: 34, - fontWeight: '700', - letterSpacing: 0.37, - fontStyle: 'italic', - }, - - // Dev pill + headerText: { flex: 1 }, + dateLabel: { fontSize: 13, fontWeight: '400', marginBottom: 2 }, + greetingNormal: { fontSize: 34, fontWeight: '700', letterSpacing: 0.37 }, + greeting: { fontSize: 34, fontWeight: '700', letterSpacing: 0.37, fontStyle: 'italic' }, devPill: { flexDirection: 'row', alignItems: 'center', @@ -361,102 +957,73 @@ const styles = StyleSheet.create({ borderRadius: 14, marginTop: 20, }, - devPillText: { - fontSize: 12, - fontWeight: '500', - }, + devPillText: { fontSize: 12, fontWeight: '500' }, - // Cards — iOS grouped-style - card: { - borderRadius: 12, + // Module cards (Throne + HealthKit) + moduleCard: { + borderRadius: 16, padding: 16, marginBottom: 12, + gap: 12, }, - cardHeader: { + moduleHeader: { flexDirection: 'row', alignItems: 'center', - gap: 6, - marginBottom: 8, + justifyContent: 'space-between', }, - cardLabel: { - fontSize: 13, - fontWeight: '600', - letterSpacing: 0.2, + moduleHeaderLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + moduleIconBox: { + width: 36, + height: 36, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', }, + moduleTitle: { fontSize: 17, fontWeight: '700' }, + moduleSubtitle: { fontSize: 12, marginTop: 1 }, + + // Metric summary + metricSummaryRow: { flexDirection: 'row', alignItems: 'baseline' }, + metricSummaryValue: { fontSize: 28, fontWeight: '700' }, + metricSummaryUnit: { fontSize: 15, fontWeight: '500' }, + metricSummaryLabel: { fontSize: 13 }, + + // Loading + loadingRow: { height: 80, alignItems: 'center', justifyContent: 'center' }, + platformNote: { fontSize: 14, fontStyle: 'italic', textAlign: 'center', paddingVertical: 12 }, + + // Activity rings section + activityRow: { flexDirection: 'row', alignItems: 'center', gap: 20 }, + activityStats: { flex: 1, gap: 10 }, + activityStatRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + ringDot: { width: 8, height: 8, borderRadius: 4 }, + activityStatLabel: { flex: 1, fontSize: 13 }, + activityStatValue: { fontSize: 13, fontWeight: '600' }, + sectionDivider: { height: StyleSheet.hairlineWidth, marginVertical: 4 }, + + // Legacy cards (surgery, timeline, etc.) + card: { borderRadius: 12, padding: 16, marginBottom: 12 }, + cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 }, + cardLabel: { fontSize: 13, fontWeight: '600', letterSpacing: 0.2 }, accentBorder: { borderLeftWidth: 3, borderTopLeftRadius: 12, borderBottomLeftRadius: 12, }, - cardValue: { - fontSize: 22, - fontWeight: '700', - letterSpacing: 0.35, - }, - cardSubtext: { - fontSize: 15, - fontWeight: '400', - marginTop: 4, - }, - placeholderBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 6, - marginLeft: 'auto', - }, - placeholderBadgeText: { - fontSize: 11, - fontWeight: '500', - }, - - // Study timeline - timelineRow: { - flexDirection: 'row', - alignItems: 'center', - }, - timelineItem: { - flex: 1, - }, - timelineLabel: { - fontSize: 13, - fontWeight: '400', - marginBottom: 2, - }, - timelineValue: { - fontSize: 17, - fontWeight: '600', - }, - timelineDivider: { - width: StyleSheet.hairlineWidth, - height: 32, - marginHorizontal: 16, - }, - - // Watch reminder - reminderCard: { - borderRadius: 12, - padding: 16, - marginBottom: 12, - }, - reminderContent: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: 12, - }, - reminderTextContainer: { - flex: 1, - }, - reminderTitle: { - fontSize: 15, - fontWeight: '600', - marginBottom: 2, - }, - reminderBody: { - fontSize: 13, - lineHeight: 18, - }, - - // All set + cardValue: { fontSize: 22, fontWeight: '700', letterSpacing: 0.35 }, + cardSubtext: { fontSize: 15, fontWeight: '400', marginTop: 4 }, + placeholderBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, marginLeft: 'auto' }, + placeholderBadgeText: { fontSize: 11, fontWeight: '500' }, + timelineRow: { flexDirection: 'row', alignItems: 'center' }, + timelineItem: { flex: 1 }, + timelineLabel: { fontSize: 13, fontWeight: '400', marginBottom: 2 }, + timelineValue: { fontSize: 17, fontWeight: '600' }, + timelineDivider: { width: StyleSheet.hairlineWidth, height: 32, marginHorizontal: 16 }, + reminderCard: { borderRadius: 12, padding: 16, marginBottom: 12 }, + reminderContent: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, + reminderTextContainer: { flex: 1 }, + reminderTitle: { fontSize: 15, fontWeight: '600', marginBottom: 2 }, + reminderBody: { fontSize: 13, lineHeight: 18 }, allSetCard: { flexDirection: 'row', alignItems: 'center', @@ -465,34 +1032,11 @@ const styles = StyleSheet.create({ padding: 16, marginBottom: 12, }, - allSetText: { - fontSize: 15, - fontWeight: '400', - }, - - // Study section - sectionTitle: { - fontSize: 17, - fontWeight: '600', - marginBottom: 6, - }, - studyBody: { - fontSize: 15, - lineHeight: 22, - }, + allSetText: { fontSize: 15, fontWeight: '400' }, - // Dev tools sheet - sheetOverlay: { - flex: 1, - justifyContent: 'flex-end', - backgroundColor: 'rgba(0,0,0,0.3)', - }, - sheetContent: { - borderTopLeftRadius: 14, - borderTopRightRadius: 14, - padding: 20, - paddingBottom: 40, - }, + // Dev sheet + sheetOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.3)' }, + sheetContent: { borderTopLeftRadius: 14, borderTopRightRadius: 14, padding: 20, paddingBottom: 40 }, sheetHandle: { width: 36, height: 5, @@ -516,17 +1060,7 @@ const styles = StyleSheet.create({ borderRadius: 10, marginBottom: 8, }, - sheetButtonText: { - fontSize: 17, - fontWeight: '400', - }, - sheetCancel: { - alignItems: 'center', - padding: 14, - marginTop: 4, - }, - sheetCancelText: { - fontSize: 17, - fontWeight: '600', - }, + sheetButtonText: { fontSize: 17, fontWeight: '400' }, + sheetCancel: { alignItems: 'center', padding: 14, marginTop: 4 }, + sheetCancelText: { fontSize: 17, fontWeight: '600' }, }); From 12abcb361f9fa27a26dc6e3ae59b3994daa5d801 Mon Sep 17 00:00:00 2001 From: James Rhee Date: Mon, 9 Mar 2026 17:42:14 -0700 Subject: [PATCH 3/8] Fix lint errors in home screen - Escape apostrophe in JSX text (react/no-unescaped-entities) - Remove unused imports: useAuth, computeSummaryStats - Remove unused variables: isDark, user, outerCenter Co-Authored-By: Claude Sonnet 4.6 --- homeflow/app/(tabs)/index.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeflow/app/(tabs)/index.tsx b/homeflow/app/(tabs)/index.tsx index 0ae6f5a..89b38be 100644 --- a/homeflow/app/(tabs)/index.tsx +++ b/homeflow/app/(tabs)/index.tsx @@ -32,7 +32,6 @@ import { useSurgeryDate } from '@/hooks/use-surgery-date'; import { useWatchUsage } from '@/hooks/use-watch-usage'; import { SurgeryCompleteModal } from '@/components/home/SurgeryCompleteModal'; import { useAppTheme } from '@/lib/theme/ThemeContext'; -import { useAuth } from '@/lib/auth/auth-context'; import { useHealthSummary } from '@/hooks/use-health-summary'; import { fetchSessions, @@ -47,7 +46,6 @@ import { import { filterByRange, bucketSeries, - computeSummaryStats, type BucketPoint, } from '@/src/data/voidingAggregation'; import { @@ -186,7 +184,6 @@ function ActivityRings({ const MIDDLE = OUTER - STROKE * 2 - GAP * 2; const INNER = MIDDLE - STROKE * 2 - GAP * 2; - const outerCenter = OUTER / 2; const middleOffset = STROKE + GAP; const innerOffset = STROKE * 2 + GAP * 2; @@ -415,8 +412,7 @@ function daysUntil(dateStr: string): string { export default function HomeScreen() { const { theme } = useAppTheme(); - const { isDark, colors: t } = theme; - const { user } = useAuth(); + const { colors: t } = theme; const surgery = useSurgeryDate(); const watch = useWatchUsage(); @@ -647,7 +643,7 @@ export default function HomeScreen() { Apple Health - Today's summary + {"Today's summary"} From 27acd4a36b279ada608899b66078d6097354f2fd Mon Sep 17 00:00:00 2001 From: chehanw Date: Mon, 9 Mar 2026 22:01:11 -0700 Subject: [PATCH 4/8] fix: restore Apple Sign In + push entitlements, add sleep sync to Firestore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeFlow.entitlements: restore aps-environment (production) and com.apple.developer.applesignin — both were accidentally removed; Apple Sign In is used on the login screen and push notifications are required for the 48h data sync reminder flow - healthkitSync.ts: add incremental sleep analysis sync pipeline (HKCategoryTypeIdentifierSleepAnalysis → Firestore, idempotent via HK UUID, batched writes, separate sync cursor per night) - use-health-summary.ts: fix sleep selection to use most-recent night (sleepData[length-1]) instead of first element - project.pbxproj: update DEVELOPMENT_TEAM to DR492LE84K --- homeflow/hooks/use-health-summary.ts | 2 +- .../ios/HomeFlow.xcodeproj/project.pbxproj | 4 +- homeflow/ios/HomeFlow/HomeFlow.entitlements | 2 +- homeflow/src/services/healthkitSync.ts | 179 +++++++++++++++++- 4 files changed, 181 insertions(+), 6 deletions(-) diff --git a/homeflow/hooks/use-health-summary.ts b/homeflow/hooks/use-health-summary.ts index cdc133a..9364a25 100644 --- a/homeflow/hooks/use-health-summary.ts +++ b/homeflow/hooks/use-health-summary.ts @@ -54,7 +54,7 @@ export function useHealthSummary(): { const todayActivity = activityData.find((d) => d.date === today) ?? (activityData.length > 0 ? activityData[activityData.length - 1] : null); - const todaySleep = sleepData.length > 0 ? sleepData[0] : null; + const todaySleep = sleepData.length > 0 ? sleepData[sleepData.length - 1] : null; const todayVitals = vitalsData.find((d) => d.date === today) ?? (vitalsData.length > 0 ? vitalsData[vitalsData.length - 1] : null); diff --git a/homeflow/ios/HomeFlow.xcodeproj/project.pbxproj b/homeflow/ios/HomeFlow.xcodeproj/project.pbxproj index c44c59e..8b3b79e 100644 --- a/homeflow/ios/HomeFlow.xcodeproj/project.pbxproj +++ b/homeflow/ios/HomeFlow.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = HomeFlow/HomeFlow.entitlements; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GA8M5C569F; + DEVELOPMENT_TEAM = DR492LE84K; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -395,7 +395,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = HomeFlow/HomeFlow.entitlements; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = GA8M5C569F; + DEVELOPMENT_TEAM = DR492LE84K; INFOPLIST_FILE = HomeFlow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/homeflow/ios/HomeFlow/HomeFlow.entitlements b/homeflow/ios/HomeFlow/HomeFlow.entitlements index e109c16..a468a7a 100644 --- a/homeflow/ios/HomeFlow/HomeFlow.entitlements +++ b/homeflow/ios/HomeFlow/HomeFlow.entitlements @@ -3,7 +3,7 @@ aps-environment - development + production com.apple.developer.applesignin Default diff --git a/homeflow/src/services/healthkitSync.ts b/homeflow/src/services/healthkitSync.ts index e7b030b..62d22e7 100644 --- a/homeflow/src/services/healthkitSync.ts +++ b/homeflow/src/services/healthkitSync.ts @@ -37,8 +37,10 @@ import { import type { FieldValue } from "firebase/firestore"; import { queryQuantitySamples, + queryCategorySamples, } from "@kingstinct/react-native-healthkit"; import type { QuantitySample } from "@kingstinct/react-native-healthkit"; +import { mapCategorySampleToSleepSample, getSleepNightDate } from "@/lib/services/healthkit/mappers"; import { db, getAuth } from "./firestore"; import { syncClinicalNotes } from "./clinicalNotesSync"; @@ -387,6 +389,172 @@ export async function syncAllHealthKit(): Promise { return { ok: allOk, results }; } +// ── Sleep sync ──────────────────────────────────────────────────────────────── + +const SLEEP_ANALYSIS_IDENTIFIER = "HKCategoryTypeIdentifierSleepAnalysis" as const; +const SLEEP_SYNC_KEY = "sleepAnalysis"; + +/** Shape written to Firestore for each sleep sample. */ +interface FirestoreSleepSampleData { + stage: string; + stageValue: number; + nightDate: string; + startDate: Timestamp; + endDate: Timestamp; + durationMinutes: number; + sourceName?: string; + deviceName?: string; + createdAt: FieldValue; + updatedAt: FieldValue; +} + +/** Result returned by syncSleep(). */ +export interface SyncSleepResult { + ok: boolean; + written: number; + error?: string; +} + +/** + * Builds a stable Firestore doc ID for a sleep category sample. + * Prefers the HK UUID, falls back to SHA-1 of key fields. + */ +async function buildSleepSampleId(sample: { + uuid?: string; + startDate: Date | string; + endDate: Date | string; + value: number; + sourceRevision?: { source?: { name?: string } }; +}): Promise { + if (sample.uuid) return sample.uuid; + + const toDate = (d: unknown): Date => + d instanceof Date ? d : new Date(String(d)); + + const sourceName = sample.sourceRevision?.source?.name ?? ""; + const input = [ + SLEEP_SYNC_KEY, + toDate(sample.startDate).toISOString(), + toDate(sample.endDate).toISOString(), + String(sample.value), + sourceName, + ].join("|"); + + return Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA1, + input, + { encoding: Crypto.CryptoEncoding.HEX }, + ); +} + +/** + * Syncs sleep analysis samples from HealthKit to Firestore. + * + * Firestore path: users/{uid}/healthkit/sleepAnalysis/samples/{sampleId} + * Sync state: users/{uid}/healthkitSync/sleepAnalysis + * + * Each sample doc stores the stage, night date, duration, and timestamps. + * Re-syncing the same sample is idempotent (stable doc ID from HK UUID). + */ +export async function syncSleep( + options?: { dryRun?: boolean }, +): Promise { + if (Platform.OS !== "ios") return { ok: true, written: 0 }; + + const uid = getAuth().currentUser?.uid; + if (!uid) { + return { ok: false, written: 0, error: "no-auth: user is not signed in" }; + } + + try { + // 1. Determine incremental window. + const lastSync = await getLastSync(uid, SLEEP_SYNC_KEY); + const sinceDate = + lastSync ?? + new Date(Date.now() - DEFAULT_LOOKBACK_DAYS * 24 * 60 * 60 * 1_000); + + const startDate = new Date(sinceDate.getTime() - OVERLAP_WINDOW_MS); + const endDate = new Date(); + + // 2. Pull sleep category samples from HealthKit. + const rawSamples = await queryCategorySamples(SLEEP_ANALYSIS_IDENTIFIER as any, { + limit: 0, + filter: { date: { startDate, endDate } }, + }); + + if (!rawSamples || rawSamples.length === 0) { + return { ok: true, written: 0 }; + } + + // 3. Transform each sample. + const toDate = (d: unknown): Date => + d instanceof Date ? d : new Date(String(d)); + + const entries: { id: string; data: FirestoreSleepSampleData }[] = + await Promise.all( + rawSamples.map(async (raw) => { + const id = await buildSleepSampleId(raw as any); + const mapped = mapCategorySampleToSleepSample(raw as any); + const nightDate = getSleepNightDate(toDate(raw.startDate)); + + const data: FirestoreSleepSampleData = { + stage: mapped.stage, + stageValue: (raw as any).value, + nightDate, + startDate: Timestamp.fromDate(toDate(raw.startDate)), + endDate: Timestamp.fromDate(toDate(raw.endDate)), + durationMinutes: mapped.durationMinutes, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }; + + const sourceName = (raw as any).sourceRevision?.source?.name; + if (sourceName) data.sourceName = sourceName; + const deviceName = (raw as any).device?.name; + if (deviceName) data.deviceName = deviceName; + + return { id, data }; + }), + ); + + if (!options?.dryRun) { + // 4. Write in batches. + const basePath = `users/${uid}/healthkit/${SLEEP_SYNC_KEY}/samples`; + console.log(`[HealthKit] Writing ${entries.length} sleep samples → ${basePath}/`); + + for (let i = 0; i < entries.length; i += BATCH_SIZE) { + const chunk = entries.slice(i, i + BATCH_SIZE); + const batch = writeBatch(db); + for (const { id, data } of chunk) { + batch.set(doc(db, `${basePath}/${id}`), data); + } + await batch.commit(); + console.log(`[HealthKit] Sleep batch committed (${chunk.length} docs)`); + } + + // 5. Advance sync cursor. + const maxEndDate = rawSamples.reduce((max, s) => { + const end = toDate((s as any).endDate); + return end > max ? end : max; + }, new Date(0)); + + await setSyncState(uid, SLEEP_SYNC_KEY, { + lastSyncedAt: Timestamp.fromDate(maxEndDate), + lastStatus: "ok", + }); + } + + return { ok: true, written: entries.length }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await setSyncState(uid, SLEEP_SYNC_KEY, { + lastStatus: "error", + lastError: message, + }).catch(() => {}); + return { ok: false, written: 0, error: message }; + } +} + // ── bootstrapHealthKitSync ──────────────────────────────────────────────────── /** @@ -397,9 +565,10 @@ export async function syncAllHealthKit(): Promise { export async function bootstrapHealthKitSync(): Promise { console.log("[HealthKit] bootstrapHealthKitSync: starting"); try { - // All three pipelines use separate HealthKit APIs — run in parallel. - const [hkResult, clinicalResult, fhirResult] = await Promise.all([ + // All four pipelines use separate HealthKit APIs — run in parallel. + const [hkResult, sleepResult, clinicalResult, fhirResult] = await Promise.all([ syncAllHealthKit(), + syncSleep(), syncClinicalNotes(), syncFhirPrefill(), ]); @@ -410,6 +579,12 @@ export async function bootstrapHealthKitSync(): Promise { console.warn("[HealthKit] bootstrapHealthKitSync: quantity metrics had errors", hkResult.results); } + if (sleepResult.ok) { + console.log(`[HealthKit] bootstrapHealthKitSync: sleep synced OK — written: ${sleepResult.written}`); + } else { + console.warn("[HealthKit] bootstrapHealthKitSync: sleep sync error:", sleepResult.error); + } + if (clinicalResult.ok) { console.log( `[HealthKit] bootstrapHealthKitSync: clinical notes synced OK — uploaded: ${clinicalResult.uploaded}, skipped: ${clinicalResult.skipped}`, From cf663cae1fdcae2bd421861c1f1cd50c47a528f3 Mon Sep 17 00:00:00 2001 From: chehanw Date: Mon, 9 Mar 2026 23:19:14 -0700 Subject: [PATCH 5/8] feat: standardize typography, fix home screen order, and fix onboarding date picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typography: - Add lib/theme/typography.ts with shared FontSize/FontWeight constants - Replace all hardcoded fontSize/fontWeight literals across every screen and component with constants (16 sizes → 11-step scale, 5 weights → 4 values) - Normalize outliers: 24→22 (titleMedium), 32→34 (display), 16/14→15 (subhead), 18→17 (headline), 10→9 (chartAxis) Home screen: - Move surgery date and study timeline cards above Throne/Apple Health modules - Replace Blood Oxygen and Respiratory Rate vitals (commonly empty, Watch-only) with Resting Heart Rate and HRV — both reliably collected from Apple Watch - Fix latent SpO2 double-multiplication display bug Onboarding date picker (chat.tsx): - Fix broken require: .DateTimePicker → .default (package uses default export) - Fix Dayjs→Date conversion in onChange handler (library returns Dayjs, not Date) - Replace all dead v2 style props with v3 styles object (selectedItemColor etc. were silently ignored); add cardinal circle for selected day, theme-aware text - Persist surgery date in OnboardingData.eligibility.surgeryDate (YYYY-MM-DD) Firestore: - Tighten security rules: scoped per-user reads for sessions/metrics/sync - Add use-throne-user-id hook --- homeflow/app/(onboarding)/chat.tsx | 38 ++- homeflow/app/(onboarding)/welcome.tsx | 17 +- homeflow/app/(tabs)/chat.tsx | 13 +- homeflow/app/(tabs)/health.tsx | 11 +- homeflow/app/(tabs)/index.tsx | 229 +++++++++--------- homeflow/app/(tabs)/profile.tsx | 59 ++--- homeflow/app/(tabs)/recovery.tsx | 33 +-- homeflow/app/(tabs)/voiding.tsx | 87 +++---- homeflow/app/throne-session.tsx | 53 ++-- .../components/health/ActivitySection.tsx | 13 +- homeflow/components/health/SleepSection.tsx | 13 +- homeflow/components/health/VitalsSection.tsx | 17 +- .../components/home/SurgeryCompleteModal.tsx | 13 +- .../components/onboarding/ContinueButton.tsx | 5 +- .../components/onboarding/PermissionCard.tsx | 13 +- homeflow/components/themed-text.tsx | 23 +- homeflow/components/ui/AccordionSection.tsx | 31 +-- homeflow/firestore.rules | 54 ++++- homeflow/hooks/use-throne-user-id.ts | 47 ++++ homeflow/ios/HomeFlow/HomeFlow.entitlements | 6 - homeflow/lib/services/onboarding-service.ts | 2 + homeflow/lib/theme/typography.ts | 52 ++++ homeflow/src/services/throneFirestore.ts | 18 ++ 23 files changed, 516 insertions(+), 331 deletions(-) create mode 100644 homeflow/hooks/use-throne-user-id.ts create mode 100644 homeflow/lib/theme/typography.ts diff --git a/homeflow/app/(onboarding)/chat.tsx b/homeflow/app/(onboarding)/chat.tsx index 9b70e9c..4efb6fb 100644 --- a/homeflow/app/(onboarding)/chat.tsx +++ b/homeflow/app/(onboarding)/chat.tsx @@ -27,7 +27,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol'; let DateTimePicker: any = null; try { // eslint-disable-next-line @typescript-eslint/no-require-imports - DateTimePicker = require('react-native-ui-datepicker').DateTimePicker; + DateTimePicker = require('react-native-ui-datepicker').default; } catch { // noop – graceful degradation below } @@ -85,6 +85,9 @@ export default function OnboardingChatScreen() { hasBPHDiagnosis: bphDiagnosis === 'yes', consideringSurgery: surgerySched === 'yes', isEligible: canContinue, + surgeryDate: surgerySched === 'yes' + ? surgeryDate.toISOString().split('T')[0] + : undefined, }, }); await OnboardingService.goToStep(OnboardingStep.CONSENT); @@ -197,18 +200,35 @@ export default function OnboardingChatScreen() { { + onChange={({ date }: { date: any }) => { if (date) { - setSurgeryDate(date); + // react-native-ui-datepicker returns a Dayjs object, + // not a native Date. Convert via valueOf() (epoch ms). + const nativeDate = + date instanceof Date + ? date + : new Date(typeof date.valueOf === 'function' ? date.valueOf() : date); + setSurgeryDate(nativeDate); setShowDatePicker(false); } }} - selectedItemColor={StanfordColors.cardinal} - headerButtonColor={StanfordColors.cardinal} - calendarTextStyle={{ color: colors.text }} - headerTextStyle={{ color: colors.text }} - weekDaysTextStyle={{ color: colors.icon }} - todayTextStyle={{ color: StanfordColors.cardinal }} + styles={{ + // Day grid + day_label: { color: colors.text }, + outside_label: { color: colors.icon }, + disabled_label: { color: colors.icon, opacity: 0.35 }, + // Weekday header row + weekday_label: { color: colors.icon }, + // Month / year selectors in header + month_selector_label: { color: colors.text, fontWeight: '600' }, + year_selector_label: { color: colors.text, fontWeight: '600' }, + // Today highlight (unfilled ring) + today: { borderWidth: 1, borderColor: StanfordColors.cardinal, borderRadius: 999 }, + today_label: { color: StanfordColors.cardinal }, + // Selected day — filled cardinal circle + selected: { backgroundColor: StanfordColors.cardinal, borderRadius: 999 }, + selected_label: { color: '#FFFFFF', fontWeight: '600' }, + }} /> ) : ( diff --git a/homeflow/app/(onboarding)/welcome.tsx b/homeflow/app/(onboarding)/welcome.tsx index 858a76f..328834d 100644 --- a/homeflow/app/(onboarding)/welcome.tsx +++ b/homeflow/app/(onboarding)/welcome.tsx @@ -20,6 +20,7 @@ import { STUDY_INFO, OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; import { ContinueButton, DevToolBar } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; export default function WelcomeScreen() { const router = useRouter(); @@ -196,13 +197,13 @@ const styles = StyleSheet.create({ alignItems: 'center', }, title: { - fontSize: 32, - fontWeight: '700', + fontSize: FontSize.display, + fontWeight: FontWeight.bold, textAlign: 'center', marginBottom: 8, }, subtitle: { - fontSize: 16, + fontSize: FontSize.subhead, textAlign: 'center', marginBottom: Spacing.lg, }, @@ -210,7 +211,7 @@ const styles = StyleSheet.create({ flex: 1, }, description: { - fontSize: 17, + fontSize: FontSize.headline, lineHeight: 24, textAlign: 'center', marginBottom: Spacing.xl, @@ -234,12 +235,12 @@ const styles = StyleSheet.create({ flex: 1, }, featureTitle: { - fontSize: 16, - fontWeight: '600', + fontSize: FontSize.subhead, + fontWeight: FontWeight.semibold, marginBottom: 2, }, featureDescription: { - fontSize: 14, + fontSize: FontSize.footnote, }, footer: { paddingHorizontal: Spacing.screenHorizontal, @@ -247,7 +248,7 @@ const styles = StyleSheet.create({ gap: Spacing.md, }, footerText: { - fontSize: 14, + fontSize: FontSize.footnote, textAlign: 'center', lineHeight: 20, }, diff --git a/homeflow/app/(tabs)/chat.tsx b/homeflow/app/(tabs)/chat.tsx index 86c7074..3c8a9af 100644 --- a/homeflow/app/(tabs)/chat.tsx +++ b/homeflow/app/(tabs)/chat.tsx @@ -23,6 +23,7 @@ import { useConciergeChat } from '@/lib/chat/useConciergeChat'; import type { QuickAction } from '@/lib/chat/useConciergeChat'; import { useAppTheme } from '@/lib/theme/ThemeContext'; import { getClientLLMProvider } from '@/lib/config/llm'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; /** * TODO: Once Firebase backend is set up, move OpenAI calls to a Cloud @@ -318,8 +319,8 @@ const styles = StyleSheet.create({ borderRadius: 10, }, yesNoText: { - fontSize: 15, - fontWeight: '500', + fontSize: FontSize.subhead, + fontWeight: FontWeight.medium, lineHeight: 20, }, @@ -343,8 +344,8 @@ const styles = StyleSheet.create({ paddingVertical: 9, }, chipText: { - fontSize: 14, - fontWeight: '500', + fontSize: FontSize.footnote, + fontWeight: FontWeight.medium, lineHeight: 18, }, @@ -354,7 +355,7 @@ const styles = StyleSheet.create({ paddingVertical: 4, }, startOverText: { - fontSize: 13, - fontWeight: '400', + fontSize: FontSize.footnote, + fontWeight: FontWeight.regular, }, }); diff --git a/homeflow/app/(tabs)/health.tsx b/homeflow/app/(tabs)/health.tsx index 992a5bc..8137819 100644 --- a/homeflow/app/(tabs)/health.tsx +++ b/homeflow/app/(tabs)/health.tsx @@ -13,6 +13,7 @@ import { SleepSection } from '@/components/health/SleepSection'; import { ActivitySection } from '@/components/health/ActivitySection'; import { VitalsSection } from '@/components/health/VitalsSection'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; export default function HealthScreen() { const { theme } = useAppTheme(); @@ -122,17 +123,17 @@ const styles = StyleSheet.create({ paddingTop: 8, }, dateLabel: { - fontSize: 13, - fontWeight: '400', + fontSize: FontSize.footnote, + fontWeight: FontWeight.regular, marginBottom: 2, }, greeting: { - fontSize: 34, - fontWeight: '700', + fontSize: FontSize.display, + fontWeight: FontWeight.bold, letterSpacing: 0.37, }, emptyText: { - fontSize: 15, + fontSize: FontSize.subhead, textAlign: 'center', lineHeight: 22, }, diff --git a/homeflow/app/(tabs)/index.tsx b/homeflow/app/(tabs)/index.tsx index 89b38be..8bcadc7 100644 --- a/homeflow/app/(tabs)/index.tsx +++ b/homeflow/app/(tabs)/index.tsx @@ -31,7 +31,10 @@ import { notifyOnboardingComplete } from '@/hooks/use-onboarding-status'; import { useSurgeryDate } from '@/hooks/use-surgery-date'; import { useWatchUsage } from '@/hooks/use-watch-usage'; import { SurgeryCompleteModal } from '@/components/home/SurgeryCompleteModal'; +import { useAuth } from '@/lib/auth/auth-context'; +import { useThroneUserId } from '@/hooks/use-throne-user-id'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; import { useHealthSummary } from '@/hooks/use-health-summary'; import { fetchSessions, @@ -288,7 +291,7 @@ function MiniBarChart({ const miniChartStyles = StyleSheet.create({ chartRow: { flexDirection: 'row', alignItems: 'stretch' }, yAxis: { width: 36, justifyContent: 'space-between', paddingRight: 6, alignItems: 'flex-end' }, - axisLabel: { fontSize: 9 }, + axisLabel: { fontSize: FontSize.chartAxis }, barArea: { flex: 1, position: 'relative' }, gridLine: { position: 'absolute', @@ -299,9 +302,9 @@ const miniChartStyles = StyleSheet.create({ barRow: { flex: 1, flexDirection: 'row', alignItems: 'flex-end', gap: 4 }, barWrapper: { flex: 1, alignItems: 'center', justifyContent: 'flex-end', height: '100%' }, xRow: { flexDirection: 'row', marginTop: 4 }, - xLabel: { fontSize: 9 }, + xLabel: { fontSize: FontSize.chartAxis }, empty: { justifyContent: 'center', alignItems: 'center' }, - emptyText: { fontSize: 13 }, + emptyText: { fontSize: FontSize.footnote }, }); // ─── Metric pill row ────────────────────────────────────────────────────────── @@ -341,7 +344,7 @@ function MetricPills({ const pillStyles = StyleSheet.create({ row: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, pill: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 20 }, - text: { fontSize: 12, fontWeight: '600' }, + text: { fontSize: FontSize.caption, fontWeight: FontWeight.semibold }, }); // ─── Vitals row ─────────────────────────────────────────────────────────────── @@ -378,8 +381,8 @@ function VitalRow({ const vitalStyles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: 10 }, iconBox: { width: 30, height: 30, borderRadius: 8, alignItems: 'center', justifyContent: 'center' }, - label: { flex: 1, fontSize: 15 }, - value: { fontSize: 15, fontWeight: '600' }, + label: { flex: 1, fontSize: FontSize.subhead }, + value: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold }, divider: { height: StyleSheet.hairlineWidth, marginLeft: 40 }, }); @@ -413,6 +416,9 @@ function daysUntil(dateStr: string): string { export default function HomeScreen() { const { theme } = useAppTheme(); const { colors: t } = theme; + const { user } = useAuth(); + const uid = user?.id ?? null; + const { throneUserId } = useThroneUserId(uid); const surgery = useSurgeryDate(); const watch = useWatchUsage(); @@ -434,8 +440,7 @@ export default function HomeScreen() { setThroneLoading(true); try { const since = new Date(Date.now() - WEEK_MS); - // Match voiding.tsx: no userId filter (Firestore userId field may differ from auth uid) - const raw: ThroneSession[] = await fetchSessions({ startDate: since }); + const raw: ThroneSession[] = await fetchSessions({ startDate: since, userId: throneUserId ?? undefined }); if (cancelled) return; const ids = raw.map((s) => s.id); @@ -464,7 +469,7 @@ export default function HomeScreen() { loadThroneData(); return () => { cancelled = true; }; - }, [refreshKey]); + }, [refreshKey, throneUserId]); const weekSessions = useMemo( () => filterByRange(sessions, '1w'), @@ -503,12 +508,12 @@ export default function HomeScreen() { ? `${Math.round(vitals.restingHeartRate)} bpm` : '—'; - const spo2Label = vitals?.oxygenSaturation - ? `${Math.round(vitals.oxygenSaturation * 100)}%` + const restingHrLabel = vitals?.restingHeartRate + ? `${Math.round(vitals.restingHeartRate)} bpm` : '—'; - const respLabel = vitals?.respiratoryRate - ? `${Math.round(vitals.respiratoryRate)} brpm` + const hrvLabel = vitals?.hrv + ? `${Math.round(vitals.hrv)} ms` : '—'; // ─── Refresh ─────────────────────────────────────────────────────────────── @@ -587,6 +592,72 @@ export default function HomeScreen() { )} + {/* ─── Surgery Date Card ─────────────────────────────────────────── */} + + + + Surgery date + {surgery.isPlaceholder && __DEV__ && ( + + + Placeholder + + + )} + + {surgery.isLoading ? ( + Loading... + ) : ( + <> + + {surgery.dateLabel} + + {surgery.date && !surgery.hasPassed && ( + + {daysUntil(surgery.date)} + + )} + {surgery.hasPassed && ( + + Surgery completed — tracking recovery + + )} + + )} + + + {/* Study Timeline */} + {!surgery.isLoading && ( + + + + Study timeline + {surgery.isPlaceholder && __DEV__ && ( + + + Placeholder + + + )} + + + + Start + + {surgery.studyStartLabel} + + + + + End + + {surgery.studyEndLabel} + + + + + )} + {/* ─── Throne Science Module ─────────────────────────────────────── */} @@ -701,7 +772,7 @@ export default function HomeScreen() { - {/* Sleep, Heart Rate, SpO2, Respiratory */} + {/* Sleep, Heart Rate, Resting Heart Rate, HRV */} - {/* ─── Surgery Date Card ─────────────────────────────────────────── */} - - - - Surgery date - {surgery.isPlaceholder && __DEV__ && ( - - - Placeholder - - - )} - - {surgery.isLoading ? ( - Loading... - ) : ( - <> - - {surgery.dateLabel} - - {surgery.date && !surgery.hasPassed && ( - - {daysUntil(surgery.date)} - - )} - {surgery.hasPassed && ( - - Surgery completed — tracking recovery - - )} - - )} - - - {/* Study Timeline */} - {!surgery.isLoading && ( - - - - Study timeline - {surgery.isPlaceholder && __DEV__ && ( - - - Placeholder - - - )} - - - - Start - - {surgery.studyStartLabel} - - - - - End - - {surgery.studyEndLabel} - - - - - )} - {/* Watch reminder */} {showWatchReminder && ( @@ -941,9 +946,9 @@ const styles = StyleSheet.create({ marginBottom: 20, }, headerText: { flex: 1 }, - dateLabel: { fontSize: 13, fontWeight: '400', marginBottom: 2 }, - greetingNormal: { fontSize: 34, fontWeight: '700', letterSpacing: 0.37 }, - greeting: { fontSize: 34, fontWeight: '700', letterSpacing: 0.37, fontStyle: 'italic' }, + dateLabel: { fontSize: FontSize.footnote, fontWeight: FontWeight.regular, marginBottom: 2 }, + greetingNormal: { fontSize: FontSize.display, fontWeight: FontWeight.bold, letterSpacing: 0.37 }, + greeting: { fontSize: FontSize.display, fontWeight: FontWeight.bold, letterSpacing: 0.37, fontStyle: 'italic' }, devPill: { flexDirection: 'row', alignItems: 'center', @@ -953,7 +958,7 @@ const styles = StyleSheet.create({ borderRadius: 14, marginTop: 20, }, - devPillText: { fontSize: 12, fontWeight: '500' }, + devPillText: { fontSize: FontSize.caption, fontWeight: FontWeight.medium }, // Module cards (Throne + HealthKit) moduleCard: { @@ -975,51 +980,51 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - moduleTitle: { fontSize: 17, fontWeight: '700' }, - moduleSubtitle: { fontSize: 12, marginTop: 1 }, + moduleTitle: { fontSize: FontSize.headline, fontWeight: FontWeight.bold }, + moduleSubtitle: { fontSize: FontSize.caption, marginTop: 1 }, // Metric summary metricSummaryRow: { flexDirection: 'row', alignItems: 'baseline' }, - metricSummaryValue: { fontSize: 28, fontWeight: '700' }, - metricSummaryUnit: { fontSize: 15, fontWeight: '500' }, - metricSummaryLabel: { fontSize: 13 }, + metricSummaryValue: { fontSize: FontSize.titleLarge, fontWeight: FontWeight.bold }, + metricSummaryUnit: { fontSize: FontSize.subhead, fontWeight: FontWeight.medium }, + metricSummaryLabel: { fontSize: FontSize.footnote }, // Loading loadingRow: { height: 80, alignItems: 'center', justifyContent: 'center' }, - platformNote: { fontSize: 14, fontStyle: 'italic', textAlign: 'center', paddingVertical: 12 }, + platformNote: { fontSize: FontSize.subhead, fontStyle: 'italic', textAlign: 'center', paddingVertical: 12 }, // Activity rings section activityRow: { flexDirection: 'row', alignItems: 'center', gap: 20 }, activityStats: { flex: 1, gap: 10 }, activityStatRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, ringDot: { width: 8, height: 8, borderRadius: 4 }, - activityStatLabel: { flex: 1, fontSize: 13 }, - activityStatValue: { fontSize: 13, fontWeight: '600' }, + activityStatLabel: { flex: 1, fontSize: FontSize.footnote }, + activityStatValue: { fontSize: FontSize.footnote, fontWeight: FontWeight.semibold }, sectionDivider: { height: StyleSheet.hairlineWidth, marginVertical: 4 }, // Legacy cards (surgery, timeline, etc.) card: { borderRadius: 12, padding: 16, marginBottom: 12 }, cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 }, - cardLabel: { fontSize: 13, fontWeight: '600', letterSpacing: 0.2 }, + cardLabel: { fontSize: FontSize.footnote, fontWeight: FontWeight.semibold, letterSpacing: 0.2 }, accentBorder: { borderLeftWidth: 3, borderTopLeftRadius: 12, borderBottomLeftRadius: 12, }, - cardValue: { fontSize: 22, fontWeight: '700', letterSpacing: 0.35 }, - cardSubtext: { fontSize: 15, fontWeight: '400', marginTop: 4 }, + cardValue: { fontSize: FontSize.titleMedium, fontWeight: FontWeight.bold, letterSpacing: 0.35 }, + cardSubtext: { fontSize: FontSize.subhead, fontWeight: FontWeight.regular, marginTop: 4 }, placeholderBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, marginLeft: 'auto' }, - placeholderBadgeText: { fontSize: 11, fontWeight: '500' }, + placeholderBadgeText: { fontSize: FontSize.micro, fontWeight: FontWeight.medium }, timelineRow: { flexDirection: 'row', alignItems: 'center' }, timelineItem: { flex: 1 }, - timelineLabel: { fontSize: 13, fontWeight: '400', marginBottom: 2 }, - timelineValue: { fontSize: 17, fontWeight: '600' }, + timelineLabel: { fontSize: FontSize.footnote, fontWeight: FontWeight.regular, marginBottom: 2 }, + timelineValue: { fontSize: FontSize.headline, fontWeight: FontWeight.semibold }, timelineDivider: { width: StyleSheet.hairlineWidth, height: 32, marginHorizontal: 16 }, reminderCard: { borderRadius: 12, padding: 16, marginBottom: 12 }, reminderContent: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, reminderTextContainer: { flex: 1 }, - reminderTitle: { fontSize: 15, fontWeight: '600', marginBottom: 2 }, - reminderBody: { fontSize: 13, lineHeight: 18 }, + reminderTitle: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold, marginBottom: 2 }, + reminderBody: { fontSize: FontSize.footnote, lineHeight: 18 }, allSetCard: { flexDirection: 'row', alignItems: 'center', @@ -1028,7 +1033,7 @@ const styles = StyleSheet.create({ padding: 16, marginBottom: 12, }, - allSetText: { fontSize: 15, fontWeight: '400' }, + allSetText: { fontSize: FontSize.subhead, fontWeight: FontWeight.regular }, // Dev sheet sheetOverlay: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.3)' }, @@ -1042,8 +1047,8 @@ const styles = StyleSheet.create({ marginBottom: 16, }, sheetTitle: { - fontSize: 13, - fontWeight: '600', + fontSize: FontSize.footnote, + fontWeight: FontWeight.semibold, letterSpacing: 0.2, marginBottom: 16, textAlign: 'center', @@ -1056,7 +1061,7 @@ const styles = StyleSheet.create({ borderRadius: 10, marginBottom: 8, }, - sheetButtonText: { fontSize: 17, fontWeight: '400' }, + sheetButtonText: { fontSize: FontSize.headline, fontWeight: FontWeight.regular }, sheetCancel: { alignItems: 'center', padding: 14, marginTop: 4 }, - sheetCancelText: { fontSize: 17, fontWeight: '600' }, + sheetCancelText: { fontSize: FontSize.headline, fontWeight: FontWeight.semibold }, }); diff --git a/homeflow/app/(tabs)/profile.tsx b/homeflow/app/(tabs)/profile.tsx index b11bb84..9f87823 100644 --- a/homeflow/app/(tabs)/profile.tsx +++ b/homeflow/app/(tabs)/profile.tsx @@ -21,6 +21,7 @@ import { STUDY_COORDINATOR, } from '@/lib/consent/consent-document'; import { useAppTheme, type AppearanceMode } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; const APPEARANCE_OPTIONS: { value: AppearanceMode; label: string }[] = [ { value: 'light', label: 'Light' }, @@ -109,7 +110,7 @@ export default function ProfileScreen() { style={[ styles.segmentText, { color: c.textSecondary }, - isSelected && { color: c.textPrimary, fontWeight: '600' }, + isSelected && { color: c.textPrimary, fontWeight: FontWeight.semibold }, ]} > {opt.label} @@ -403,8 +404,8 @@ const styles = StyleSheet.create({ paddingTop: 8, }, screenTitle: { - fontSize: 34, - fontWeight: '700', + fontSize: FontSize.display, + fontWeight: FontWeight.bold, letterSpacing: 0.37, marginBottom: 20, }, @@ -422,22 +423,22 @@ const styles = StyleSheet.create({ marginBottom: 8, }, cardLabel: { - fontSize: 13, - fontWeight: '600', + fontSize: FontSize.footnote, + fontWeight: FontWeight.semibold, letterSpacing: 0.2, }, // Account info accountName: { - fontSize: 18, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, color: '#2C3E50', }, accountNameDark: { color: '#D4D8E8', }, accountEmail: { - fontSize: 14, + fontSize: FontSize.footnote, color: '#7A7F8E', marginTop: 2, }, @@ -445,7 +446,7 @@ const styles = StyleSheet.create({ color: '#6B7394', }, placeholderText: { - fontSize: 15, + fontSize: FontSize.subhead, lineHeight: 22, fontStyle: 'italic', }, @@ -470,8 +471,8 @@ const styles = StyleSheet.create({ elevation: 2, }, segmentText: { - fontSize: 13, - fontWeight: '500', + fontSize: FontSize.footnote, + fontWeight: FontWeight.medium, }, // Dev tools @@ -484,8 +485,8 @@ const styles = StyleSheet.create({ paddingVertical: 12, }, devButtonText: { - fontSize: 14, - fontWeight: '500', + fontSize: FontSize.footnote, + fontWeight: FontWeight.medium, }, // Sign out @@ -499,8 +500,8 @@ const styles = StyleSheet.create({ marginBottom: 16, }, signOutText: { - fontSize: 16, - fontWeight: '600', + fontSize: FontSize.subhead, + fontWeight: FontWeight.semibold, color: '#D64545', }, @@ -517,8 +518,8 @@ const styles = StyleSheet.create({ gap: 10, }, rowLabel: { - fontSize: 17, - fontWeight: '400', + fontSize: FontSize.headline, + fontWeight: FontWeight.regular, }, rowDivider: { height: StyleSheet.hairlineWidth, @@ -539,17 +540,17 @@ const styles = StyleSheet.create({ }, bulletText: { flex: 1, - fontSize: 15, + fontSize: FontSize.subhead, lineHeight: 22, }, // Contact contactName: { - fontSize: 17, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, }, contactRole: { - fontSize: 13, + fontSize: FontSize.footnote, marginTop: 2, marginBottom: 14, }, @@ -565,7 +566,7 @@ const styles = StyleSheet.create({ borderRadius: 10, }, contactButtonText: { - fontSize: 15, + fontSize: FontSize.subhead, }, // Modal @@ -589,20 +590,20 @@ const styles = StyleSheet.create({ marginBottom: 16, }, modalTitle: { - fontSize: 17, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, textAlign: 'center', marginBottom: 12, }, modalBody: { - fontSize: 15, + fontSize: FontSize.subhead, lineHeight: 22, textAlign: 'center', marginBottom: 24, }, modalSubhead: { - fontSize: 13, - fontWeight: '500', + fontSize: FontSize.footnote, + fontWeight: FontWeight.medium, marginBottom: 12, textAlign: 'left', }, @@ -613,8 +614,8 @@ const styles = StyleSheet.create({ marginTop: 8, }, modalButtonText: { - fontSize: 17, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, color: '#FFFFFF', }, }); diff --git a/homeflow/app/(tabs)/recovery.tsx b/homeflow/app/(tabs)/recovery.tsx index 1d458c4..097ac72 100644 --- a/homeflow/app/(tabs)/recovery.tsx +++ b/homeflow/app/(tabs)/recovery.tsx @@ -20,6 +20,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; import { useSurgeryDate } from '@/hooks/use-surgery-date'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { @@ -119,15 +120,15 @@ const tlStyles = StyleSheet.create({ card: { borderRadius: 14, padding: 16, marginBottom: 16 }, statusRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }, - statusLabel: { fontSize: 15, fontWeight: '600' }, - statusSub: { fontSize: 12 }, + statusLabel: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold }, + statusSub: { fontSize: FontSize.caption }, track: { height: 6, borderRadius: 3, overflow: 'hidden', marginBottom: 24 }, fill: { height: '100%', borderRadius: 3 }, milestonesRow: { position: 'relative', height: 32 }, milestone: { position: 'absolute', alignItems: 'center', transform: [{ translateX: -20 }] }, dot: { width: 8, height: 8, borderRadius: 4, borderWidth: 1.5, marginBottom: 4 }, - milestoneLabel: { fontSize: 10, fontWeight: '500', textAlign: 'center', width: 48 }, + milestoneLabel: { fontSize: FontSize.micro, fontWeight: FontWeight.medium, textAlign: 'center', width: 48 }, }); // ─── Section content components ─────────────────────────────────────────────── @@ -358,10 +359,10 @@ function ReviewedToggle({ const rvStyles = StyleSheet.create({ btn: { paddingTop: 10, alignItems: 'center' }, - btnText: { fontSize: 12, fontWeight: '500' }, + btnText: { fontSize: FontSize.caption, fontWeight: FontWeight.medium }, done: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 10, paddingHorizontal: 10, paddingVertical: 6, borderRadius: 8, alignSelf: 'flex-start' }, - doneText: { fontSize: 12, fontWeight: '600' }, + doneText: { fontSize: FontSize.caption, fontWeight: FontWeight.semibold }, }); // ─── Main Tab Screen ────────────────────────────────────────────────────────── @@ -536,7 +537,7 @@ export default function RecoveryScreen() { const sc = StyleSheet.create({ groupLabel: { - fontSize: 11, fontWeight: '600', textTransform: 'uppercase', + fontSize: FontSize.micro, fontWeight: FontWeight.semibold, textTransform: 'uppercase', letterSpacing: 0.4, marginTop: 4, marginBottom: 6, }, divider: { @@ -546,7 +547,7 @@ const sc = StyleSheet.create({ flexDirection: 'row', gap: 8, padding: 12, borderRadius: 10, borderWidth: 1, marginBottom: 8, alignItems: 'flex-start', }, - infoText: { flex: 1, fontSize: 13, lineHeight: 18 }, + infoText: { flex: 1, fontSize: FontSize.footnote, lineHeight: 18 }, checkRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12, padding: 12, borderRadius: 10, borderWidth: 1, @@ -555,12 +556,12 @@ const sc = StyleSheet.create({ width: 20, height: 20, borderRadius: 5, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center', }, - checkLabel: { fontSize: 13, fontWeight: '500' }, + checkLabel: { fontSize: FontSize.footnote, fontWeight: FontWeight.medium }, callButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 13, borderRadius: 12, marginTop: 4, }, - callButtonText: { color: '#FFFFFF', fontSize: 15, fontWeight: '600' }, + callButtonText: { color: '#FFFFFF', fontSize: FontSize.subhead, fontWeight: FontWeight.semibold }, }); // ─── Screen styles ──────────────────────────────────────────────────────────── @@ -569,19 +570,19 @@ const styles = StyleSheet.create({ container: { flex: 1 }, scrollContent:{ paddingHorizontal: 16, paddingTop: 8, paddingBottom: 0 }, - screenTitle: { fontSize: 34, fontWeight: '700', paddingTop: 8, paddingBottom: 2 }, + screenTitle: { fontSize: FontSize.display, fontWeight: FontWeight.bold, paddingTop: 8, paddingBottom: 2 }, headerBlock: { marginBottom: 20 }, - subtitle: { fontSize: 15, lineHeight: 22, marginBottom: 2 }, - sourceNote: { fontSize: 11, fontWeight: '500', textTransform: 'uppercase', letterSpacing: 0.4 }, + subtitle: { fontSize: FontSize.subhead, lineHeight: 22, marginBottom: 2 }, + sourceNote: { fontSize: FontSize.micro, fontWeight: FontWeight.medium, textTransform: 'uppercase', letterSpacing: 0.4 }, footerCard: { borderRadius: 14, padding: 16, marginTop: 6, marginBottom: 12 }, - footerTitle: { fontSize: 15, fontWeight: '600', marginBottom: 6 }, - footerBody: { fontSize: 13, lineHeight: 19, marginBottom: 14 }, + footerTitle: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold, marginBottom: 6 }, + footerBody: { fontSize: FontSize.footnote, lineHeight: 19, marginBottom: 14 }, footerButtons: { flexDirection: 'row', gap: 10 }, footerBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 11, borderRadius: 10 }, footerBtnOutline: { backgroundColor: 'transparent', borderWidth: 1 }, - footerBtnText: { color: '#FFFFFF', fontSize: 14, fontWeight: '600' }, + footerBtnText: { color: '#FFFFFF', fontSize: FontSize.footnote, fontWeight: FontWeight.semibold }, - disclaimer: { fontSize: 11, lineHeight: 16, textAlign: 'center', paddingHorizontal: 8, marginBottom: 8 }, + disclaimer: { fontSize: FontSize.micro, lineHeight: 16, textAlign: 'center', paddingHorizontal: 8, marginBottom: 8 }, }); diff --git a/homeflow/app/(tabs)/voiding.tsx b/homeflow/app/(tabs)/voiding.tsx index 52ac4f2..1cba32f 100644 --- a/homeflow/app/(tabs)/voiding.tsx +++ b/homeflow/app/(tabs)/voiding.tsx @@ -30,7 +30,9 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; import { useAuth } from '@/lib/auth/auth-context'; +import { useThroneUserId } from '@/hooks/use-throne-user-id'; import { fetchSessions, fetchMetricsBatch, @@ -149,7 +151,7 @@ function PillRow({ const pillStyles = StyleSheet.create({ row: { flexDirection: 'row', gap: 8 }, pill: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 20 }, - text: { fontSize: 13, fontWeight: '600' }, + text: { fontSize: FontSize.caption, fontWeight: FontWeight.semibold }, }); /** A single metric summary card. */ @@ -179,10 +181,10 @@ function StatCard({ const statStyles = StyleSheet.create({ card: { flex: 1, borderRadius: 12, padding: 14, marginHorizontal: 4 }, - label: { fontSize: 11, fontWeight: '500', marginBottom: 4, textTransform: 'uppercase', letterSpacing: 0.3 }, + label: { fontSize: FontSize.micro, fontWeight: FontWeight.medium, marginBottom: 4, textTransform: 'uppercase', letterSpacing: 0.3 }, valueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 3 }, - value: { fontSize: 22, fontWeight: '700' }, - unit: { fontSize: 12, fontWeight: '500' }, + value: { fontSize: FontSize.titleMedium, fontWeight: FontWeight.bold }, + unit: { fontSize: FontSize.caption, fontWeight: FontWeight.medium }, }); /** Bar chart — same approach as throne-session.tsx FlowCurveChart. */ @@ -273,15 +275,15 @@ function TrendBarChart({ const chartStyles = StyleSheet.create({ chartRow: { flexDirection: 'row', alignItems: 'stretch' }, yAxis: { width: 36, justifyContent: 'space-between', paddingRight: 6, alignItems: 'flex-end' }, - axisLabel: { fontSize: 9 }, + axisLabel: { fontSize: FontSize.chartAxis }, barArea: { flex: 1, position: 'relative' }, gridLine: { position: 'absolute', left: 0, right: 0, borderBottomWidth: StyleSheet.hairlineWidth }, barRow: { flex: 1, flexDirection: 'row', alignItems: 'flex-end', gap: 4, paddingBottom: 0 }, barWrapper:{ flex: 1, alignItems: 'center', justifyContent: 'flex-end', height: '100%' }, xRow: { flexDirection: 'row', marginTop: 4 }, - xLabel: { fontSize: 9 }, + xLabel: { fontSize: FontSize.chartAxis }, empty: { justifyContent: 'center', alignItems: 'center' }, - emptyText: { fontSize: 13 }, + emptyText: { fontSize: FontSize.footnote }, }); /** One column of the pre/post comparison card. */ @@ -353,14 +355,14 @@ function ComparisonColumn({ const cmpStyles = StyleSheet.create({ column: { flex: 1 }, - columnTitle: { fontSize: 13, fontWeight: '600', marginBottom: 2 }, - columnSub: { fontSize: 11, marginBottom: 10 }, - noData: { fontSize: 12, fontStyle: 'italic', marginTop: 8 }, + columnTitle: { fontSize: FontSize.footnote, fontWeight: FontWeight.semibold, marginBottom: 2 }, + columnSub: { fontSize: FontSize.micro, marginBottom: 10 }, + noData: { fontSize: FontSize.caption, fontStyle: 'italic', marginTop: 8 }, row: { marginBottom: 8 }, - rowLabel: { fontSize: 11, fontWeight: '500', marginBottom: 2 }, + rowLabel: { fontSize: FontSize.micro, fontWeight: FontWeight.medium, marginBottom: 2 }, rowValueRow: { flexDirection: 'row', alignItems: 'baseline' }, - rowValue: { fontSize: 16, fontWeight: '600' }, - rowUnit: { fontSize: 11, fontWeight: '400' }, + rowValue: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold }, + rowUnit: { fontSize: FontSize.micro, fontWeight: FontWeight.regular }, }); /** Delta badge shown below the comparison columns. */ @@ -386,7 +388,7 @@ function DeltaBadge({ {label}:{' '} - + {sign}{delta.toFixed(1)} {unit} {pctStr} @@ -397,7 +399,7 @@ function DeltaBadge({ const deltaStyles = StyleSheet.create({ badge: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8, marginTop: 10 }, - text: { fontSize: 12 }, + text: { fontSize: FontSize.caption }, }); // ─── Surgery Date Picker Modal ──────────────────────────────────────────────── @@ -523,16 +525,16 @@ function SurgeryDateModal({ const mdlStyles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: '#00000066', justifyContent: 'center', alignItems: 'center' }, sheet: { borderRadius: 20, padding: 24, width: '80%', alignItems: 'center' }, - title: { fontSize: 17, fontWeight: '600', marginBottom: 4 }, - sub: { fontSize: 13, textAlign: 'center', marginBottom: 24 }, + title: { fontSize: FontSize.headline, fontWeight: FontWeight.semibold, marginBottom: 4 }, + sub: { fontSize: FontSize.footnote, textAlign: 'center', marginBottom: 24 }, pickersRow: { flexDirection: 'row', gap: 24, marginBottom: 28 }, pickerCol: { alignItems: 'center', gap: 8, minWidth: 56 }, - pickerLabel: { fontSize: 11, fontWeight: '500', textTransform: 'uppercase', letterSpacing: 0.3 }, - arrow: { fontSize: 18, fontWeight: '600' }, - pickerValue: { fontSize: 20, fontWeight: '600', minWidth: 44, textAlign: 'center' }, + pickerLabel: { fontSize: FontSize.micro, fontWeight: FontWeight.medium, textTransform: 'uppercase', letterSpacing: 0.3 }, + arrow: { fontSize: FontSize.headline, fontWeight: FontWeight.semibold }, + pickerValue: { fontSize: FontSize.titleSmall, fontWeight: FontWeight.semibold, minWidth: 44, textAlign: 'center' }, buttonRow: { flexDirection: 'row', gap: 12 }, btn: { flex: 1, paddingVertical: 12, borderRadius: 12, alignItems: 'center' }, - btnText: { fontSize: 15, fontWeight: '600' }, + btnText: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold }, }); // ─── Main Screen ────────────────────────────────────────────────────────────── @@ -542,6 +544,7 @@ export default function VoidingScreen() { const { isDark, colors: c } = theme; const { user } = useAuth(); const uid = user?.id ?? null; + const { throneUserId } = useThroneUserId(uid); // ── UI state ────────────────────────────────────────────────────────────── const [range, setRange] = useState('1w'); @@ -592,7 +595,7 @@ export default function VoidingScreen() { async function load() { try { const startDate = new Date(Date.now() - NINETY_DAYS_MS); - const data = await fetchSessions({ startDate }); + const data = await fetchSessions({ startDate, userId: throneUserId ?? undefined }); if (cancelled) return; setAllSessions(data); @@ -621,7 +624,7 @@ export default function VoidingScreen() { load(); return () => { cancelled = true; }; - }, [refreshKey]); + }, [refreshKey, throneUserId]); // ─── Pull-to-refresh ────────────────────────────────────────────────────── const handleRefresh = useCallback(() => { @@ -1050,8 +1053,8 @@ function MetricChip({ const chipStyles = StyleSheet.create({ chip: { flexDirection: 'row', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 }, - label: { fontSize: 11, fontWeight: '500' }, - value: { fontSize: 11, fontWeight: '600' }, + label: { fontSize: FontSize.micro, fontWeight: FontWeight.medium }, + value: { fontSize: FontSize.micro, fontWeight: FontWeight.semibold }, }); // ─── Styles ─────────────────────────────────────────────────────────────────── @@ -1062,53 +1065,53 @@ const styles = StyleSheet.create({ list: { paddingHorizontal: 16, paddingBottom: 40 }, emptyState: { paddingTop: 24, alignItems: 'center' }, - header: { fontSize: 34, fontWeight: '700', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 4 }, - subtitle: { fontSize: 15, textAlign: 'center', lineHeight: 22 }, + header: { fontSize: FontSize.display, fontWeight: FontWeight.bold, paddingHorizontal: 16, paddingTop: 8, paddingBottom: 4 }, + subtitle: { fontSize: FontSize.subhead, textAlign: 'center', lineHeight: 22 }, titleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingRight: 16, marginBottom: 4 }, chip: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: 16 }, - chipText: { fontSize: 11, fontWeight: '600' }, + chipText: { fontSize: FontSize.micro, fontWeight: FontWeight.semibold }, controlsCard: { paddingHorizontal: 16, marginBottom: 10 }, - sectionLabel: { fontSize: 13, fontWeight: '500', marginTop: 20, marginBottom: 8, marginLeft: 4 }, + sectionLabel: { fontSize: FontSize.footnote, fontWeight: FontWeight.semibold, marginTop: 20, marginBottom: 8, marginLeft: 4 }, statsRow: { flexDirection: 'row', marginBottom: 4 }, card: { borderRadius: 14, padding: 16, marginBottom: 10 }, chartMetaRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 12 }, - chartMetric: { fontSize: 14, fontWeight: '600' }, - chartUnit: { fontSize: 11 }, + chartMetric: { fontSize: FontSize.footnote, fontWeight: FontWeight.semibold }, + chartUnit: { fontSize: FontSize.micro }, // CTA card - ctaTitle: { fontSize: 15, fontWeight: '600', marginBottom: 6 }, - ctaBody: { fontSize: 13, lineHeight: 18, marginBottom: 16 }, + ctaTitle: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold, marginBottom: 6 }, + ctaBody: { fontSize: FontSize.footnote, lineHeight: 18, marginBottom: 16 }, ctaButton: { paddingVertical: 12, borderRadius: 10, alignItems: 'center' }, - ctaButtonText: { fontSize: 14, fontWeight: '600', color: '#FFFFFF' }, + ctaButtonText: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold, color: '#FFFFFF' }, // Comparison cmpHeader: { marginBottom: 4 }, cmpToggleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - cmpToggleLabel:{ fontSize: 14, fontWeight: '500' }, + cmpToggleLabel:{ fontSize: FontSize.subhead, fontWeight: FontWeight.medium }, separator: { height: StyleSheet.hairlineWidth, marginVertical: 14 }, windowControls:{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 10 }, - windowLabel: { fontSize: 11, fontWeight: '500', minWidth: 72 }, + windowLabel: { fontSize: FontSize.micro, fontWeight: FontWeight.medium, minWidth: 72 }, cmpColumns: { flexDirection: 'row', gap: 0 }, cmpDivider: { width: StyleSheet.hairlineWidth, marginHorizontal: 16 }, // Session list sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', paddingTop: 8, paddingBottom: 6 }, - sectionTitle: { fontSize: 15, fontWeight: '600' }, - sectionCount: { fontSize: 12, fontWeight: '500' }, + sectionTitle: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold }, + sectionCount: { fontSize: FontSize.caption, fontWeight: FontWeight.medium }, cardRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }, - cardTime: { fontSize: 16, fontWeight: '600' }, - cardStatus: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.5 }, + cardTime: { fontSize: FontSize.subhead, fontWeight: FontWeight.semibold }, + cardStatus: { fontSize: FontSize.micro, fontWeight: FontWeight.semibold, textTransform: 'uppercase', letterSpacing: 0.5 }, metricChips: { flexDirection: 'row', gap: 6, flexWrap: 'wrap', flex: 1 }, - cardChevron: { fontSize: 18, fontWeight: '300', paddingLeft: 8 }, + cardChevron: { fontSize: FontSize.headline, fontWeight: '300', paddingLeft: 8 }, tagsRow: { flexDirection: 'row', gap: 6, marginTop: 4 }, tag: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 }, - tagText: { fontSize: 11, fontWeight: '500' }, + tagText: { fontSize: FontSize.micro, fontWeight: FontWeight.medium }, }); diff --git a/homeflow/app/throne-session.tsx b/homeflow/app/throne-session.tsx index 4b89d40..efcc6ce 100644 --- a/homeflow/app/throne-session.tsx +++ b/homeflow/app/throne-session.tsx @@ -20,6 +20,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, router } from 'expo-router'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; import { fetchSessions, fetchMetricsForSession, @@ -87,8 +88,8 @@ const statStyles = StyleSheet.create({ marginHorizontal: 4, }, label: { - fontSize: 12, - fontWeight: '500', + fontSize: FontSize.caption, + fontWeight: FontWeight.medium, marginBottom: 4, }, valueRow: { @@ -97,12 +98,12 @@ const statStyles = StyleSheet.create({ gap: 3, }, value: { - fontSize: 24, - fontWeight: '700', + fontSize: FontSize.titleMedium, + fontWeight: FontWeight.bold, }, unit: { - fontSize: 13, - fontWeight: '500', + fontSize: FontSize.footnote, + fontWeight: FontWeight.medium, }, }); @@ -122,7 +123,7 @@ function FlowCurveChart({ if (flowPoints.length === 0) { return ( - + No flow data recorded @@ -184,7 +185,7 @@ const chartStyles = StyleSheet.create({ paddingRight: 6, }, axisLabel: { - fontSize: 10, + fontSize: FontSize.chartAxis, textAlign: 'right', }, barArea: { @@ -484,12 +485,12 @@ const styles = StyleSheet.create({ paddingVertical: 10, }, backText: { - fontSize: 17, - fontWeight: '400', + fontSize: FontSize.body, + fontWeight: FontWeight.regular, }, headerTitle: { - fontSize: 17, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, }, scrollContent: { paddingHorizontal: 16, @@ -501,7 +502,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, errorText: { - fontSize: 17, + fontSize: FontSize.body, }, card: { borderRadius: 12, @@ -516,8 +517,8 @@ const styles = StyleSheet.create({ marginBottom: 8, }, sessionStatus: { - fontSize: 13, - fontWeight: '600', + fontSize: FontSize.footnote, + fontWeight: FontWeight.semibold, textTransform: 'uppercase', letterSpacing: 0.5, }, @@ -531,8 +532,8 @@ const styles = StyleSheet.create({ borderRadius: 6, }, tagText: { - fontSize: 12, - fontWeight: '500', + fontSize: FontSize.caption, + fontWeight: FontWeight.medium, }, infoRow: { flexDirection: 'row', @@ -542,17 +543,17 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, }, infoLabel: { - fontSize: 14, - fontWeight: '500', + fontSize: FontSize.subhead, + fontWeight: FontWeight.medium, }, infoValue: { - fontSize: 14, - fontWeight: '400', + fontSize: FontSize.subhead, + fontWeight: FontWeight.regular, maxWidth: '60%', }, sectionLabel: { - fontSize: 13, - fontWeight: '500', + fontSize: FontSize.footnote, + fontWeight: FontWeight.semibold, marginTop: 16, marginBottom: 8, marginLeft: 4, @@ -569,7 +570,7 @@ const styles = StyleSheet.create({ paddingLeft: 36, }, axisText: { - fontSize: 10, + fontSize: FontSize.chartAxis, }, tableRow: { flexDirection: 'row', @@ -581,7 +582,7 @@ const styles = StyleSheet.create({ paddingBottom: 6, }, tableCell: { - fontSize: 13, + fontSize: FontSize.footnote, }, tableCellTime: { flex: 1, @@ -589,6 +590,6 @@ const styles = StyleSheet.create({ tableCellValue: { width: 80, textAlign: 'right', - fontWeight: '500', + fontWeight: FontWeight.medium, }, }); diff --git a/homeflow/components/health/ActivitySection.tsx b/homeflow/components/health/ActivitySection.tsx index 8aef742..0e82f20 100644 --- a/homeflow/components/health/ActivitySection.tsx +++ b/homeflow/components/health/ActivitySection.tsx @@ -3,6 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { IconSymbol } from '@/components/ui/icon-symbol'; import type { ActivityInsight } from '@/lib/services/health-summary'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; interface ActivitySectionProps { insight: ActivityInsight; @@ -73,25 +74,25 @@ const styles = StyleSheet.create({ gap: 6, }, sectionLabel: { - fontSize: 13, - fontWeight: '600', + fontSize: FontSize.footnote, + fontWeight: FontWeight.semibold, letterSpacing: 0.2, }, headline: { - fontSize: 20, - fontWeight: '700', + fontSize: FontSize.titleSmall, + fontWeight: FontWeight.bold, letterSpacing: 0.38, marginBottom: 4, }, supporting: { - fontSize: 15, + fontSize: FontSize.subhead, lineHeight: 22, }, details: { marginTop: 16, }, detailRow: { - fontSize: 15, + fontSize: FontSize.subhead, marginTop: 8, }, }); diff --git a/homeflow/components/health/SleepSection.tsx b/homeflow/components/health/SleepSection.tsx index 476b528..432f1d2 100644 --- a/homeflow/components/health/SleepSection.tsx +++ b/homeflow/components/health/SleepSection.tsx @@ -4,6 +4,7 @@ import { IconSymbol } from '@/components/ui/icon-symbol'; import { DurationBar } from './DurationBar'; import type { SleepInsight } from '@/lib/services/health-summary'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; interface SleepSectionProps { insight: SleepInsight; @@ -93,25 +94,25 @@ const styles = StyleSheet.create({ gap: 6, }, sectionLabel: { - fontSize: 13, - fontWeight: '600', + fontSize: FontSize.footnote, + fontWeight: FontWeight.semibold, letterSpacing: 0.2, }, headline: { - fontSize: 20, - fontWeight: '700', + fontSize: FontSize.titleSmall, + fontWeight: FontWeight.bold, letterSpacing: 0.38, marginBottom: 4, }, supporting: { - fontSize: 15, + fontSize: FontSize.subhead, lineHeight: 22, }, details: { marginTop: 16, }, detailRow: { - fontSize: 15, + fontSize: FontSize.subhead, marginTop: 8, }, stagesContainer: { diff --git a/homeflow/components/health/VitalsSection.tsx b/homeflow/components/health/VitalsSection.tsx index 71d596a..b4e3145 100644 --- a/homeflow/components/health/VitalsSection.tsx +++ b/homeflow/components/health/VitalsSection.tsx @@ -3,6 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { IconSymbol } from '@/components/ui/icon-symbol'; import type { VitalsInsight } from '@/lib/services/health-summary'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; interface VitalsSectionProps { insight: VitalsInsight; @@ -79,18 +80,18 @@ const styles = StyleSheet.create({ gap: 6, }, sectionLabel: { - fontSize: 13, - fontWeight: '600', + fontSize: FontSize.footnote, + fontWeight: FontWeight.semibold, letterSpacing: 0.2, }, headline: { - fontSize: 20, - fontWeight: '700', + fontSize: FontSize.titleSmall, + fontWeight: FontWeight.bold, letterSpacing: 0.38, marginBottom: 4, }, supporting: { - fontSize: 15, + fontSize: FontSize.subhead, lineHeight: 22, }, details: { @@ -103,11 +104,11 @@ const styles = StyleSheet.create({ }, vitalLabel: { flex: 1, - fontSize: 15, + fontSize: FontSize.subhead, }, vitalValue: { - fontSize: 15, - fontWeight: '600', + fontSize: FontSize.subhead, + fontWeight: FontWeight.semibold, }, divider: { height: StyleSheet.hairlineWidth, diff --git a/homeflow/components/home/SurgeryCompleteModal.tsx b/homeflow/components/home/SurgeryCompleteModal.tsx index 837959d..9ffd395 100644 --- a/homeflow/components/home/SurgeryCompleteModal.tsx +++ b/homeflow/components/home/SurgeryCompleteModal.tsx @@ -17,6 +17,7 @@ import { } from 'react-native'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { useAppTheme } from '@/lib/theme/ThemeContext'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; interface SurgeryCompleteModalProps { visible: boolean; @@ -128,20 +129,20 @@ const styles = StyleSheet.create({ marginBottom: 20, }, title: { - fontSize: 22, - fontWeight: '700', + fontSize: FontSize.titleMedium, + fontWeight: FontWeight.bold, marginBottom: 12, textAlign: 'center', letterSpacing: 0.35, }, body: { - fontSize: 15, + fontSize: FontSize.subhead, lineHeight: 22, textAlign: 'center', marginBottom: 8, }, subtext: { - fontSize: 13, + fontSize: FontSize.footnote, textAlign: 'center', marginBottom: 28, }, @@ -153,8 +154,8 @@ const styles = StyleSheet.create({ alignItems: 'center', }, buttonText: { - fontSize: 17, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, color: '#FFFFFF', }, }); diff --git a/homeflow/components/onboarding/ContinueButton.tsx b/homeflow/components/onboarding/ContinueButton.tsx index 0040d87..55c1e72 100644 --- a/homeflow/components/onboarding/ContinueButton.tsx +++ b/homeflow/components/onboarding/ContinueButton.tsx @@ -14,6 +14,7 @@ import { ViewStyle, } from 'react-native'; import { StanfordColors, Colors } from '@/constants/theme'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; interface ContinueButtonProps { title?: string; @@ -91,7 +92,7 @@ const styles = StyleSheet.create({ height: 44, }, text: { - fontSize: 17, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, }, }); diff --git a/homeflow/components/onboarding/PermissionCard.tsx b/homeflow/components/onboarding/PermissionCard.tsx index a92e87a..a09a6bf 100644 --- a/homeflow/components/onboarding/PermissionCard.tsx +++ b/homeflow/components/onboarding/PermissionCard.tsx @@ -16,6 +16,7 @@ import { } from 'react-native'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors, StanfordColors, Spacing } from '@/constants/theme'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; export type PermissionStatus = 'not_determined' | 'granted' | 'denied' | 'skipped' | 'loading'; @@ -198,12 +199,12 @@ const styles = StyleSheet.create({ padding: 4, }, title: { - fontSize: 18, - fontWeight: '600', + fontSize: FontSize.headline, + fontWeight: FontWeight.semibold, marginBottom: 4, }, description: { - fontSize: 14, + fontSize: FontSize.footnote, lineHeight: 20, marginBottom: Spacing.md, }, @@ -222,14 +223,14 @@ const styles = StyleSheet.create({ minHeight: 44, }, buttonText: { - fontSize: 16, - fontWeight: '600', + fontSize: FontSize.subhead, + fontWeight: FontWeight.semibold, }, skipButton: { paddingVertical: 12, paddingHorizontal: 16, }, skipText: { - fontSize: 14, + fontSize: FontSize.footnote, }, }); diff --git a/homeflow/components/themed-text.tsx b/homeflow/components/themed-text.tsx index d79d0a1..886713a 100644 --- a/homeflow/components/themed-text.tsx +++ b/homeflow/components/themed-text.tsx @@ -1,6 +1,7 @@ import { StyleSheet, Text, type TextProps } from 'react-native'; import { useThemeColor } from '@/hooks/use-theme-color'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; export type ThemedTextProps = TextProps & { lightColor?: string; @@ -35,26 +36,26 @@ export function ThemedText({ const styles = StyleSheet.create({ default: { - fontSize: 16, - lineHeight: 24, + fontSize: FontSize.subhead, + lineHeight: 22, }, defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', + fontSize: FontSize.subhead, + lineHeight: 22, + fontWeight: FontWeight.semibold, }, title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, + fontSize: FontSize.display, + fontWeight: FontWeight.bold, + lineHeight: 40, }, subtitle: { - fontSize: 20, - fontWeight: 'bold', + fontSize: FontSize.titleSmall, + fontWeight: FontWeight.bold, }, link: { lineHeight: 30, - fontSize: 16, + fontSize: FontSize.subhead, color: '#0a7ea4', }, }); diff --git a/homeflow/components/ui/AccordionSection.tsx b/homeflow/components/ui/AccordionSection.tsx index ae39da2..23010ba 100644 --- a/homeflow/components/ui/AccordionSection.tsx +++ b/homeflow/components/ui/AccordionSection.tsx @@ -23,6 +23,7 @@ import { } from 'react-native'; import { useAppTheme } from '@/lib/theme/ThemeContext'; import { IconSymbol, type IconSymbolName } from '@/components/ui/icon-symbol'; +import { FontSize, FontWeight } from '@/lib/theme/typography'; // Enable LayoutAnimation on Android if (Platform.OS === 'android') { @@ -241,13 +242,13 @@ const styles = StyleSheet.create({ gap: 2, }, title: { - fontSize: 15, - fontWeight: '600', + fontSize: FontSize.subhead, + fontWeight: FontWeight.semibold, letterSpacing: -0.1, }, summary: { - fontSize: 12, - fontWeight: '400', + fontSize: FontSize.caption, + fontWeight: FontWeight.regular, }, badge: { paddingHorizontal: 8, @@ -255,8 +256,8 @@ const styles = StyleSheet.create({ borderRadius: 10, }, badgeText: { - fontSize: 11, - fontWeight: '500', + fontSize: FontSize.micro, + fontWeight: FontWeight.medium, }, content: { paddingHorizontal: 16, @@ -277,12 +278,12 @@ const bulletStyles = StyleSheet.create({ paddingLeft: 16, }, dot: { - fontSize: 14, + fontSize: FontSize.footnote, lineHeight: 20, width: 12, }, text: { - fontSize: 14, + fontSize: FontSize.footnote, lineHeight: 20, flex: 1, }, @@ -304,11 +305,11 @@ const stepStyles = StyleSheet.create({ marginTop: 1, }, num: { - fontSize: 12, - fontWeight: '700', + fontSize: FontSize.caption, + fontWeight: FontWeight.bold, }, text: { - fontSize: 14, + fontSize: FontSize.footnote, lineHeight: 20, flex: 1, }, @@ -323,13 +324,13 @@ const pairStyles = StyleSheet.create({ gap: 8, }, label: { - fontSize: 13, - fontWeight: '500', + fontSize: FontSize.footnote, + fontWeight: FontWeight.medium, flex: 1, }, value: { - fontSize: 13, - fontWeight: '400', + fontSize: FontSize.footnote, + fontWeight: FontWeight.regular, flex: 2, textAlign: 'right', }, diff --git a/homeflow/firestore.rules b/homeflow/firestore.rules index a374bc3..72e1f7f 100644 --- a/homeflow/firestore.rules +++ b/homeflow/firestore.rules @@ -3,17 +3,47 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { - // This rule allows anyone with your Firestore database reference to view, edit, - // and delete all data in your Firestore database. It is useful for getting - // started, but it is configured to expire after 30 days because it - // leaves your app open to attackers. At that time, all client - // requests to your Firestore database will be denied. - // - // Make sure to write security rules for your app before that time, or else - // all client requests to your Firestore database will be denied until you Update - // your rules - match /{document=**} { - allow read, write: if request.time < timestamp.date(2026, 3, 14); + // ── Helpers ─────────────────────────────────────────────────────────────── + + // Returns the caller's Throne-internal user ID from their profile doc. + // Returns null if the profile doesn't exist or the field isn't set. + function callerThroneUserId() { + let profile = get(/databases/$(database)/documents/users/$(request.auth.uid)); + return profile.data.get('throneUserId', null); + } + + // ── User profiles ───────────────────────────────────────────────────────── + + match /users/{uid} { + // Users can only read and write their own profile. + allow read, write: if request.auth != null && request.auth.uid == uid; + } + + // ── Throne sessions ─────────────────────────────────────────────────────── + + match /sessions/{sessionId} { + // A user may only read sessions whose Throne userId matches theirs. + // Writes come exclusively from the Cloud Function (Admin SDK bypasses rules). + allow read: if request.auth != null + && callerThroneUserId() != null + && resource.data.userId == callerThroneUserId(); + allow write: if false; + } + + // ── Throne metrics ──────────────────────────────────────────────────────── + + match /metrics/{metricId} { + // Metrics are fetched only by sessionId, which callers can only know + // from their own sessions query (guarded above). Require authentication. + allow read: if request.auth != null; + allow write: if false; + } + + // ── Throne sync state ───────────────────────────────────────────────────── + + match /throneSync/{studyId} { + // Admin / Cloud Function only — no client access. + allow read, write: if false; } } -} \ No newline at end of file +} diff --git a/homeflow/hooks/use-throne-user-id.ts b/homeflow/hooks/use-throne-user-id.ts new file mode 100644 index 0000000..67ead66 --- /dev/null +++ b/homeflow/hooks/use-throne-user-id.ts @@ -0,0 +1,47 @@ +/** + * useThroneUserId + * + * Reads the Throne-internal user ID for the signed-in Firebase user. + * The Throne userId is stored in users/{uid}.throneUserId by the study + * coordinator during participant enrollment. + * + * This ID is used to filter Firestore session queries so each user only + * sees their own uroflow data. + */ + +import { useState, useEffect } from 'react'; +import { fetchThroneUserId } from '@/src/services/throneFirestore'; + +interface ThroneUserIdState { + /** The Throne-internal user ID, or null if not yet enrolled / not found */ + throneUserId: string | null; + /** True while the Firestore read is in flight */ + isLoading: boolean; +} + +export function useThroneUserId(uid: string | null): ThroneUserIdState { + const [state, setState] = useState({ + throneUserId: null, + isLoading: uid !== null, + }); + + useEffect(() => { + if (!uid) { + setState({ throneUserId: null, isLoading: false }); + return; + } + + let cancelled = false; + setState({ throneUserId: null, isLoading: true }); + + fetchThroneUserId(uid).then((id) => { + if (!cancelled) setState({ throneUserId: id, isLoading: false }); + }).catch(() => { + if (!cancelled) setState({ throneUserId: null, isLoading: false }); + }); + + return () => { cancelled = true; }; + }, [uid]); + + return state; +} diff --git a/homeflow/ios/HomeFlow/HomeFlow.entitlements b/homeflow/ios/HomeFlow/HomeFlow.entitlements index a468a7a..dca2b85 100644 --- a/homeflow/ios/HomeFlow/HomeFlow.entitlements +++ b/homeflow/ios/HomeFlow/HomeFlow.entitlements @@ -2,12 +2,6 @@ - aps-environment - production - com.apple.developer.applesignin - - Default - com.apple.developer.healthkit com.apple.developer.healthkit.access diff --git a/homeflow/lib/services/onboarding-service.ts b/homeflow/lib/services/onboarding-service.ts index 11136a6..d116d1b 100644 --- a/homeflow/lib/services/onboarding-service.ts +++ b/homeflow/lib/services/onboarding-service.ts @@ -18,6 +18,8 @@ export interface OnboardingData { hasBPHDiagnosis: boolean; consideringSurgery: boolean; isEligible: boolean; + /** YYYY-MM-DD string of the scheduled surgery date, if provided */ + surgeryDate?: string; }; // Medical history (from chatbot) diff --git a/homeflow/lib/theme/typography.ts b/homeflow/lib/theme/typography.ts new file mode 100644 index 0000000..8e076f9 --- /dev/null +++ b/homeflow/lib/theme/typography.ts @@ -0,0 +1,52 @@ +/** + * App-wide typography scale + * + * All screens and components should import from here instead of + * hardcoding fontSize / fontWeight values. When a font family is + * chosen it only needs to be added in one place. + * + * Size scale (iOS HIG-aligned): + * display 34 — Screen hero / large titles + * titleLarge 28 — Big metric numbers + * titleMedium 22 — Card value headings + * titleSmall 20 — Section headings + * headline 17 — Module / card titles, nav bar titles, button text + * body 17 — Body content (same size as headline, lighter weight) + * subhead 15 — Supporting text, descriptions, vital labels + * footnote 13 — Secondary labels, dates, card labels + * caption 12 — Pills, chips, stat units + * micro 11 — Badges, uppercase micro-labels + * chartAxis 9 — Chart axis labels only + */ + +export const FontSize = { + display: 34, + titleLarge: 28, + titleMedium: 22, + titleSmall: 20, + headline: 17, + body: 17, + subhead: 15, + footnote: 13, + caption: 12, + micro: 11, + chartAxis: 9, +} as const; + +export const FontWeight = { + bold: '700' as const, + semibold: '600' as const, + medium: '500' as const, + regular: '400' as const, +} as const; + +/** Convenience line-height values paired with the size scale. */ +export const LineHeight = { + display: 40, + titleLarge: 34, + titleSmall: 26, + headline: 22, + subhead: 22, + footnote: 18, + caption: 16, +} as const; diff --git a/homeflow/src/services/throneFirestore.ts b/homeflow/src/services/throneFirestore.ts index d0adae6..2e1cf38 100644 --- a/homeflow/src/services/throneFirestore.ts +++ b/homeflow/src/services/throneFirestore.ts @@ -143,6 +143,24 @@ export async function fetchSurgeryDate(uid: string): Promise { return null; } +/** + * Read the Throne user ID for a given Firebase UID. + * Looks for `throneUserId` field in users/{uid}. + * Returns null if the field is not set. + */ +export async function fetchThroneUserId(uid: string): Promise { + try { + const snap = await getDoc(doc(db, 'users', uid)); + if (snap.exists()) { + const val = snap.data()?.throneUserId; + if (typeof val === 'string' && val) return val; + } + } catch { + // Document may not exist — return null + } + return null; +} + /** * Persist surgery date to Firestore at users/{uid}/settings. * Uses merge so existing fields are not overwritten. From 58ca926f7d723e2cac2d2d064b9837535d13754a Mon Sep 17 00:00:00 2001 From: chehanw Date: Tue, 10 Mar 2026 13:18:32 -0700 Subject: [PATCH 6/8] feat: clean up dev tools, fix HealthKit data, add light mode to symptom tracker - Remove all dev-only screens (health-data-test, fhir-parser-test) and DevToolBar component from all onboarding screens and profile tab; keep surgery date trigger, reset onboarding, and skip-auth dev tools - Fix navigation in permissions.tsx to route to medical-history after DevToolBar removal - Autofill patient name from typed consent signature into medical history form - Fix "You're All Set" screen feature text invisible (alignSelf stretch on Animated.View) - Fix HealthKit UTC/local timezone mismatch in use-health-summary (use formatDateKey instead of toISOString) so today's activity/vitals are found correctly after ~5 PM - Add per-query .catch guards in getDailyActivity and getVitals so Watch-only type failures don't block step count and energy data - Migrate symptom tracker to useAppTheme for full light/dark mode support --- homeflow/app/(onboarding)/_layout.tsx | 12 - homeflow/app/(onboarding)/account.tsx | 19 +- homeflow/app/(onboarding)/baseline-survey.tsx | 4 +- homeflow/app/(onboarding)/chat.tsx | 15 +- homeflow/app/(onboarding)/complete.tsx | 4 +- homeflow/app/(onboarding)/consent.tsx | 26 +- .../app/(onboarding)/fhir-parser-test.tsx | 577 ------------ .../app/(onboarding)/health-data-test.tsx | 674 -------------- homeflow/app/(onboarding)/ineligible.tsx | 3 +- homeflow/app/(onboarding)/medical-history.tsx | 448 ++++++---- homeflow/app/(onboarding)/permissions.tsx | 229 ++++- homeflow/app/(onboarding)/welcome.tsx | 3 +- homeflow/app/(tabs)/index.tsx | 44 +- homeflow/app/(tabs)/profile.tsx | 91 -- homeflow/app/(tabs)/tracker.tsx | 48 +- homeflow/app/(tabs)/voiding.tsx | 12 +- homeflow/app/_layout.tsx | 49 +- homeflow/app/fhir-parser-test.tsx | 839 ------------------ homeflow/app/throne-session.tsx | 12 +- homeflow/components/onboarding/DevToolBar.tsx | 173 ---- homeflow/components/onboarding/index.ts | 1 - homeflow/hooks/use-health-summary.ts | 5 +- homeflow/lib/constants.ts | 11 +- .../lib/services/healthkit/HealthKitClient.ts | 28 +- homeflow/src/services/consentPdfSync.ts | 183 ++++ homeflow/storage.rules | 16 + 26 files changed, 852 insertions(+), 2674 deletions(-) delete mode 100644 homeflow/app/(onboarding)/fhir-parser-test.tsx delete mode 100644 homeflow/app/(onboarding)/health-data-test.tsx delete mode 100644 homeflow/app/fhir-parser-test.tsx delete mode 100644 homeflow/components/onboarding/DevToolBar.tsx create mode 100644 homeflow/src/services/consentPdfSync.ts create mode 100644 homeflow/storage.rules diff --git a/homeflow/app/(onboarding)/_layout.tsx b/homeflow/app/(onboarding)/_layout.tsx index f9d211e..932be83 100644 --- a/homeflow/app/(onboarding)/_layout.tsx +++ b/homeflow/app/(onboarding)/_layout.tsx @@ -67,18 +67,6 @@ export default function OnboardingLayout() { animation: 'slide_from_right', }} /> - - { + // Flush any surgery date collected before login to Firestore now that we have a UID. + const uid = getAuth().currentUser?.uid; + if (uid) { + const data = await OnboardingService.getData(); + const surgeryDate = data.eligibility?.surgeryDate; + if (surgeryDate) { + saveSurgeryDate(uid, surgeryDate).catch((err) => { + console.warn('[Account] Failed to flush surgery date to Firestore:', err); + }); + } + } + await OnboardingService.goToStep(OnboardingStep.PERMISSIONS); router.push('/(onboarding)/permissions' as Href); }; @@ -259,10 +272,6 @@ export default function AccountScreen() { - ); } diff --git a/homeflow/app/(onboarding)/baseline-survey.tsx b/homeflow/app/(onboarding)/baseline-survey.tsx index 84f2ecd..0239a33 100644 --- a/homeflow/app/(onboarding)/baseline-survey.tsx +++ b/homeflow/app/(onboarding)/baseline-survey.tsx @@ -24,7 +24,7 @@ import { calculateIPSSScore, getIPSSSeverityDescription, } from '@/lib/questionnaires/ipss-questionnaire'; -import { OnboardingProgressBar, ContinueButton, DevToolBar } from '@/components/onboarding'; +import { OnboardingProgressBar, ContinueButton } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; export default function BaselineSurveyScreen() { @@ -197,7 +197,6 @@ export default function BaselineSurveyScreen() { /> - ); } @@ -226,7 +225,6 @@ export default function BaselineSurveyScreen() { /> - ); } diff --git a/homeflow/app/(onboarding)/chat.tsx b/homeflow/app/(onboarding)/chat.tsx index 4efb6fb..b56fdc2 100644 --- a/homeflow/app/(onboarding)/chat.tsx +++ b/homeflow/app/(onboarding)/chat.tsx @@ -20,7 +20,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; -import { OnboardingProgressBar, ContinueButton, DevToolBar } from '@/components/onboarding'; +import { OnboardingProgressBar, ContinueButton } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; // Lazy-load so the screen still renders even if the package isn't available @@ -79,17 +79,23 @@ export default function OnboardingChatScreen() { }; const handleContinue = async () => { + const dateStr = surgerySched === 'yes' + ? surgeryDate.toISOString().split('T')[0] + : undefined; + await OnboardingService.updateData({ eligibility: { hasIPhone: true, hasBPHDiagnosis: bphDiagnosis === 'yes', consideringSurgery: surgerySched === 'yes', isEligible: canContinue, - surgeryDate: surgerySched === 'yes' - ? surgeryDate.toISOString().split('T')[0] - : undefined, + surgeryDate: dateStr, }, }); + + // Surgery date is persisted locally in OnboardingService (AsyncStorage). + // It will be flushed to Firestore after the user logs in (account.tsx). + await OnboardingService.goToStep(OnboardingStep.CONSENT); router.push('/(onboarding)/consent' as Href); }; @@ -250,7 +256,6 @@ export default function OnboardingChatScreen() { /> - ); } diff --git a/homeflow/app/(onboarding)/complete.tsx b/homeflow/app/(onboarding)/complete.tsx index 87aa980..e2ce7ac 100644 --- a/homeflow/app/(onboarding)/complete.tsx +++ b/homeflow/app/(onboarding)/complete.tsx @@ -20,7 +20,7 @@ import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { STUDY_INFO, OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; import { notifyOnboardingComplete } from '@/hooks/use-onboarding-status'; -import { ContinueButton, DevToolBar } from '@/components/onboarding'; +import { ContinueButton } from '@/components/onboarding'; import { devSkipAuth } from '@/lib/dev-flags'; import { IconSymbol } from '@/components/ui/icon-symbol'; @@ -145,6 +145,7 @@ export default function CompleteScreen() { style={{ opacity: contentFade, transform: [{ translateY: contentSlide }], + alignSelf: 'stretch', }} > @@ -207,7 +208,6 @@ export default function CompleteScreen() { )} - ); } diff --git a/homeflow/app/(onboarding)/consent.tsx b/homeflow/app/(onboarding)/consent.tsx index 79c2646..ed49c21 100644 --- a/homeflow/app/(onboarding)/consent.tsx +++ b/homeflow/app/(onboarding)/consent.tsx @@ -36,11 +36,11 @@ import { OnboardingProgressBar, ConsentAgreement, ContinueButton, - DevToolBar, } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { SignaturePad, type SignaturePadRef } from '@/components/ui/SignaturePad'; import { useAuth } from '@/hooks/use-auth'; +import { uploadConsentPdf } from '@/src/services/consentPdfSync'; function buildConsentText(): string { const header = [ @@ -95,6 +95,7 @@ export default function ConsentScreen() { const [isSubmitting, setIsSubmitting] = useState(false); const [emailModalVisible, setEmailModalVisible] = useState(false); const [emailAddress, setEmailAddress] = useState(user?.email ?? ''); + const [scrollEnabled, setScrollEnabled] = useState(true); const scrollViewRef = useRef(null); const signaturePadRef = useRef(null); @@ -112,16 +113,29 @@ export default function ConsentScreen() { setIsSubmitting(true); try { - // Record consent — store typed name or a drawn-signature marker + const participantName = + signatureMode === 'type' ? typedSignature.trim() : null; const signatureValue = signatureMode === 'type' ? typedSignature.trim() : '[Drawn signature provided]'; + + // Record consent locally (source of truth for gate-keeping) await ConsentService.recordConsent(signatureValue); - // Update onboarding - await OnboardingService.goToStep(OnboardingStep.ACCOUNT); + // Upload signed PDF to Firebase Storage + write Firestore metadata. + // Non-fatal: failure should not block the participant from proceeding. + const pdfResult = await uploadConsentPdf({ + signatureType: signatureMode === 'type' ? 'typed' : 'drawn', + participantName, + signatureValue, + }); + if (!pdfResult.ok) { + console.warn('[Consent] PDF upload failed (non-fatal):', pdfResult.error); + } + // Advance onboarding + await OnboardingService.goToStep(OnboardingStep.ACCOUNT); router.push('/(onboarding)/account' as Href); } finally { setIsSubmitting(false); @@ -224,6 +238,7 @@ export default function ConsentScreen() { contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={true} keyboardShouldPersistTaps="handled" + scrollEnabled={scrollEnabled} > {/* Flat consent document */} {CONSENT_DOCUMENT.sections.map((section, index) => ( @@ -330,6 +345,7 @@ export default function ConsentScreen() { setScrollEnabled(!active)} strokeColor={colorScheme === 'dark' ? '#FFFFFF' : '#1A1A1A'} backgroundColor={colorScheme === 'dark' ? '#2C2C2E' : '#F9F9F9'} height={160} @@ -407,8 +423,6 @@ export default function ConsentScreen() { /> - - {/* Email address prompt modal */} e?.resource) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .filter((r: any): r is Record => - r != null && typeof r.resourceType === 'string', - ); - } - if (Array.isArray(raw?.resources)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (raw.resources as any[]).filter(Boolean); - } - if (raw?.resourceType) return [raw]; - return []; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function rawDisplayName(resource: any): string { - const rt: string = resource.resourceType ?? ''; - if (rt === 'MedicationRequest' || rt === 'MedicationOrder' || rt === 'MedicationStatement') { - const cc = resource.medicationCodeableConcept ?? resource.medication; - if (cc?.text) return cc.text as string; - if (Array.isArray(cc?.coding) && cc.coding[0]?.display) - return cc.coding[0].display as string; - if (resource.medicationReference?.display) - return resource.medicationReference.display as string; - return rt; - } - const cc = resource.code; - if (cc?.text) return cc.text as string; - if (Array.isArray(cc?.coding) && cc.coding[0]?.display) - return cc.coding[0].display as string; - return rt; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function buildClinicalInput(resources: any[]): ClinicalRecordsInput { - const medications: ClinicalRecordsInput['medications'] = []; - const labResults: ClinicalRecordsInput['labResults'] = []; - const conditions: ClinicalRecordsInput['conditions'] = []; - const procedures: ClinicalRecordsInput['procedures'] = []; - - for (const r of resources) { - const displayName = rawDisplayName(r); - const fhirResource = r as Record; - switch (r.resourceType as string) { - case 'MedicationRequest': - case 'MedicationOrder': - case 'MedicationStatement': - medications.push({ displayName, fhirResource }); - break; - case 'Observation': - case 'DiagnosticReport': - labResults.push({ displayName, fhirResource }); - break; - case 'Condition': - conditions.push({ displayName, fhirResource }); - break; - case 'Procedure': - procedures.push({ displayName, fhirResource }); - break; - } - } - return { medications, labResults, conditions, procedures }; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractDemographics(resources: any[]): HealthKitDemographics | null { - const patient = resources.find((r) => r.resourceType === 'Patient'); - if (!patient) return null; - - let age: number | null = null; - const birthDate: string | null = - typeof patient.birthDate === 'string' ? patient.birthDate : null; - - if (birthDate) { - const born = new Date(birthDate); - const now = new Date(); - age = now.getFullYear() - born.getFullYear(); - const m = now.getMonth() - born.getMonth(); - if (m < 0 || (m === 0 && now.getDate() < born.getDate())) age -= 1; - } - - const biologicalSex: string | null = - typeof patient.gender === 'string' ? patient.gender : null; - - return { age, dateOfBirth: birthDate, biologicalSex }; -} - -// ── Parse result ────────────────────────────────────────────────────── - -interface ParseResult { - prefill: MedicalHistoryPrefill; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rawResources: any[]; - resourceCount: number; - durationMs: number; -} - -function runParser(key: FixtureKey): ParseResult { - const raw = FIXTURE_MAP[key]; - const resources = normalizeToResources(raw); - const clinicalInput = buildClinicalInput(resources); - const demographics = extractDemographics(resources); - const t0 = Date.now(); - const prefill = buildMedicalHistoryPrefill(clinicalInput, demographics); - return { prefill, rawResources: resources, resourceCount: resources.length, durationMs: Date.now() - t0 }; -} - -// ── Confidence badge ────────────────────────────────────────────────── - -const CONF_COLOR: Record = { - high: '#30D158', - medium: '#FF9F0A', - low: '#FF453A', - none: '#8E8E93', -}; - -function ConfidenceBadge({ confidence }: { confidence: string }) { - const color = CONF_COLOR[confidence] ?? '#8E8E93'; - return ( - - {confidence} - - ); -} - -const badgeS = StyleSheet.create({ - pill: { paddingHorizontal: 7, paddingVertical: 2, borderRadius: 8 }, - text: { fontSize: 10, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.4 }, -}); - -// ── Prefill row ─────────────────────────────────────────────────────── - -function PrefillRow({ - label, - entry, - renderValue, - colors, -}: { - label: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - entry: PrefillEntry; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - renderValue?: (v: any) => string; - colors: typeof Colors.light; -}) { - const hasValue = entry.value != null; - const displayValue = hasValue - ? renderValue ? renderValue(entry.value) : String(entry.value) - : '—'; - - return ( - - {label} - - - {displayValue} - - - - - ); -} - -const rowS = StyleSheet.create({ - row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingVertical: 7, gap: 8 }, - label: { fontSize: 14, flex: 1, paddingTop: 1 }, - right: { flexDirection: 'row', alignItems: 'center', gap: 6, flexShrink: 1, maxWidth: '65%' }, - value: { fontSize: 14, fontWeight: '500', textAlign: 'right', flexShrink: 1 }, - empty: { fontStyle: 'italic' }, -}); - -// ── Section card ────────────────────────────────────────────────────── - -function SectionCard({ - title, - icon, - children, - colorScheme, -}: { - title: string; - icon: string; - children: React.ReactNode; - colorScheme: string | null | undefined; -}) { - const bg = colorScheme === 'dark' ? '#1C1C1E' : '#F8F8FA'; - return ( - - - - {title} - - - {children} - - ); -} - -const sCardS = StyleSheet.create({ - card: { borderRadius: 12, padding: 14, marginBottom: 10 }, - header: { flexDirection: 'row', alignItems: 'center', gap: 7, marginBottom: 10 }, - title: { fontSize: 12, fontWeight: '700', letterSpacing: 0.5, textTransform: 'uppercase', color: '#8E8E93' }, - divider: { height: StyleSheet.hairlineWidth, backgroundColor: '#3C3C4333', marginBottom: 6 }, -}); - -// ── Collapsible JSON viewer ─────────────────────────────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function JsonViewer({ data, colorScheme }: { data: any; colorScheme: string | null | undefined }) { - const [expanded, setExpanded] = useState(false); - const json = JSON.stringify(data, null, 2); - const count = Array.isArray(data) ? (data as unknown[]).length : null; - const bg = colorScheme === 'dark' ? '#1C1C1E' : '#F8F8FA'; - - return ( - - setExpanded((v) => !v)} activeOpacity={0.7}> - - - {expanded ? 'Hide Raw FHIR Resources' : 'Show Raw FHIR Resources'} - - {count != null && ( - {count} resource{count !== 1 ? 's' : ''} - )} - - {expanded && ( - - {json} - - )} - - ); -} - -const jsonS = StyleSheet.create({ - card: { borderRadius: 12, padding: 14, marginBottom: 10 }, - toggleRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - toggleText: { fontSize: 15, fontWeight: '500', flex: 1, color: '#007AFF' }, - count: { fontSize: 12, color: '#8E8E93' }, - scrollBox: { maxHeight: 360, marginTop: 12, backgroundColor: '#1A1A2E', borderRadius: 10, padding: 12 }, - jsonText: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', color: '#A5D6A7', lineHeight: 16 }, -}); - -// ── Run state ───────────────────────────────────────────────────────── - -type RunState = - | { status: 'idle' } - | { status: 'success'; result: ParseResult } - | { status: 'error'; message: string }; - -// ── Main screen ─────────────────────────────────────────────────────── - -export default function FhirParserTestOnboarding() { - const router = useRouter(); - const colorScheme = useColorScheme(); - const colors = Colors[colorScheme ?? 'light']; - - // Production: auto-skip this screen - useEffect(() => { - if (!__DEV__) { - (async () => { - await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); - router.replace('/(onboarding)/medical-history' as Href); - })(); - } - }, [router]); - - const handleContinue = useCallback(async () => { - await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); - router.push('/(onboarding)/medical-history' as Href); - }, [router]); - - const [selectedFixture, setSelectedFixture] = useState(FIXTURE_KEYS[0]); - const [runState, setRunState] = useState({ status: 'idle' }); - - const handleRun = useCallback(() => { - try { - const result = runParser(selectedFixture); - setRunState({ status: 'success', result }); - } catch (err) { - setRunState({ status: 'error', message: err instanceof Error ? err.message : String(err) }); - } - }, [selectedFixture]); - - const handleCopy = useCallback(async () => { - if (runState.status !== 'success') return; - const json = JSON.stringify(runState.result.prefill, null, 2); - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const Clipboard = require('expo-clipboard'); - await (Clipboard.setStringAsync as (s: string) => Promise)(json); - Alert.alert('Copied', 'Prefill output JSON copied to clipboard.'); - } catch { - try { await Share.share({ message: json }); } catch { /* no-op */ } - } - }, [runState]); - - // Don't render test UI in production - if (!__DEV__) { - return ( - - - - ); - } - - const prefill = runState.status === 'success' ? runState.result.prefill : null; - const rawResources = runState.status === 'success' ? runState.result.rawResources : []; - const isDark = colorScheme === 'dark'; - const cardBg = isDark ? '#1C1C1E' : '#F8F8FA'; - - return ( - - - {/* Header */} - - - FHIR Parser Test - - - Dev-only screen. Select a fixture and run the medical history parser. - - - {/* Dev banner */} - - - DEV ONLY — skipped in production builds - - - {/* Fixture selector */} - - Select Fixture - - {FIXTURE_KEYS.map((key) => { - const active = selectedFixture === key; - return ( - setSelectedFixture(key)} - activeOpacity={0.7} - > - - {active && } - - {key} - - ); - })} - - - - Run Parser - - - {runState.status === 'success' && ( - - - - Parsed in {runState.result.durationMs} ms · {runState.result.resourceCount} resources - - - )} - {runState.status === 'error' && ( - <> - - - Parse error - - {runState.message} - - )} - - - {/* Prefill output */} - {prefill && ( - <> - {/* 1. Demographics */} - - - `${v} yrs`} colors={colors} /> - - - - - - {/* 2. Medications */} - - v.map((m: { name: string }) => m.name).join(', ')} colors={colors} /> - v.map((m: { name: string }) => m.name).join(', ')} colors={colors} /> - v.map((m: { name: string }) => m.name).join(', ')} colors={colors} /> - v.map((m: { name: string }) => m.name).join(', ')} colors={colors} /> - v.map((m: { name: string }) => m.name).join(', ')} colors={colors} /> - - - {/* 3. Surgical History */} - - v.map((p: { name: string }) => p.name).join(', ')} colors={colors} /> - `${v.length} procedure${v.length !== 1 ? 's' : ''}`} colors={colors} /> - - - {/* 4. Labs */} - - `${v.value} ${v.unit} (${(v.date as string).slice(0, 10)})`} colors={colors} /> - `${v.value}${v.unit} (${(v.date as string).slice(0, 10)})`} colors={colors} /> - `${v.value} ${v.unit} (${(v.date as string).slice(0, 10)})`} colors={colors} /> - - - {/* 5. Conditions */} - - v.map((c: { name: string }) => c.name).join(', ')} colors={colors} /> - v.map((c: { name: string }) => c.name).join(', ')} colors={colors} /> - v.map((c: { name: string }) => c.name).join(', ')} colors={colors} /> - `${v.length} condition${v.length !== 1 ? 's' : ''}`} colors={colors} /> - - - {/* 6. Clinical Measurements */} - - `${v.value} ${v.unit}`} colors={colors} /> - `${v.value} ${v.unit}`} colors={colors} /> - - - - {/* 7. Upcoming Surgery */} - - - - - - {/* Copy JSON */} - - - Copy Output JSON - - - {/* Raw FHIR */} - - - )} - - - - - {/* Footer */} - - - - - - - ); -} - -// ── Styles ──────────────────────────────────────────────────────────── - -const s = StyleSheet.create({ - container: { flex: 1 }, - scroll: { flex: 1 }, - scrollContent: { padding: Spacing.screenHorizontal, paddingBottom: 40 }, - - titleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, marginBottom: Spacing.xs, marginTop: Spacing.sm }, - title: { fontSize: 26, fontWeight: '700' }, - subtitle: { fontSize: 14, lineHeight: 20, textAlign: 'center', marginBottom: Spacing.md }, - - devBanner: { - flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 6, backgroundColor: '#FF9F0A22', borderRadius: 10, - paddingVertical: 8, marginBottom: Spacing.md, - }, - devBannerText: { fontSize: 12, fontWeight: '600', color: '#FF9F0A', letterSpacing: 0.2 }, - - card: { borderRadius: 12, padding: 14, marginBottom: 12 }, - sectionHeader: { fontSize: 13, fontWeight: '700', color: '#8E8E93', letterSpacing: 0.4, textTransform: 'uppercase', marginBottom: 12 }, - - fixtureRow: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingVertical: 10, paddingHorizontal: 12, - borderRadius: 10, borderWidth: 1.5, marginBottom: 8, - }, - radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, - radioDot: { width: 10, height: 10, borderRadius: 5 }, - fixtureLabel: { fontSize: 15, fontWeight: '500' }, - - runBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 13, borderRadius: 12, marginTop: 8 }, - runBtnText: { fontSize: 16, fontWeight: '600', color: '#FFF' }, - - statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 10 }, - statusText: { fontSize: 13, fontWeight: '500' }, - errorDetail: { fontSize: 12, color: '#FF453A', marginTop: 4 }, - - copyBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, borderRadius: 12, paddingVertical: 13, marginBottom: 10 }, - copyBtnText: { fontSize: 15, fontWeight: '600', color: '#007AFF' }, - - footer: { padding: Spacing.md, paddingBottom: Spacing.lg, borderTopWidth: StyleSheet.hairlineWidth }, -}); diff --git a/homeflow/app/(onboarding)/health-data-test.tsx b/homeflow/app/(onboarding)/health-data-test.tsx deleted file mode 100644 index 77f0a5c..0000000 --- a/homeflow/app/(onboarding)/health-data-test.tsx +++ /dev/null @@ -1,674 +0,0 @@ -/** - * Health Data Test Screen (Dev Only) - * - * Sits between Permissions and Medical History in the onboarding flow. - * In production (__DEV__ === false), auto-skips to medical history. - * In dev mode, shows a full testing UI for HealthKit + Clinical Records queries. - */ - -import React, { useState, useCallback, useEffect } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - useColorScheme, - TouchableOpacity, - ActivityIndicator, - Platform, -} from 'react-native'; -import { useRouter, Href } from 'expo-router'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Colors, StanfordColors, Spacing } from '@/constants/theme'; -import { OnboardingStep } from '@/lib/constants'; -import { OnboardingService } from '@/lib/services/onboarding-service'; -import { - OnboardingProgressBar, - ContinueButton, - DevToolBar, -} from '@/components/onboarding'; -import { IconSymbol } from '@/components/ui/icon-symbol'; - -import { - requestHealthPermissions, - getDailyActivity, - getSleep, - getVitals, - areClinicalRecordsAvailable, - requestClinicalPermissions, - getClinicalMedications, - getClinicalLabResults, - getClinicalConditions, - getClinicalProcedures, -} from '@/lib/services/healthkit'; -import type { DateRange } from '@/lib/services/healthkit'; - -// ── Types ─────────────────────────────────────────────────────────── - -type TestStatus = 'idle' | 'running' | 'success' | 'error'; - -interface TestResult { - label: string; - status: TestStatus; - data?: unknown; - error?: string; - count?: number; -} - -// ── Helpers ───────────────────────────────────────────────────────── - -function getLast7DaysRange(): DateRange { - const end = new Date(); - const start = new Date(); - start.setDate(start.getDate() - 7); - return { startDate: start, endDate: end }; -} - -function truncateJSON(obj: unknown, maxLength = 500): string { - const str = JSON.stringify(obj, null, 2); - if (str.length <= maxLength) return str; - return str.slice(0, maxLength) + '\n... (truncated)'; -} - -// ── Main Screen ───────────────────────────────────────────────────── - -export default function HealthDataTestScreen() { - const router = useRouter(); - const colorScheme = useColorScheme(); - const colors = Colors[colorScheme ?? 'light']; - - // In production, auto-skip this screen - useEffect(() => { - if (!__DEV__) { - (async () => { - await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); - router.replace('/(onboarding)/medical-history' as Href); - })(); - } - }, [router]); - - const handleContinue = async () => { - if (__DEV__) { - // In dev builds, proceed to the FHIR parser test screen next - router.push('/(onboarding)/fhir-parser-test' as Href); - } else { - await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); - router.push('/(onboarding)/medical-history' as Href); - } - }; - - // Don't render test UI in production - if (!__DEV__) { - return ( - - - - ); - } - - return ( - - - - - - - - - - Health Data Test - - - - - Dev-only screen. Test HealthKit and Clinical Records queries before proceeding. - - - - - - - - - - - - - - - - - - - - - ); -} - -// ── Status Banner ─────────────────────────────────────────────────── - -function StatusBanner({ - colors, - colorScheme, -}: { - colors: typeof Colors.light; - colorScheme: string | null | undefined; -}) { - const isIOS = Platform.OS === 'ios'; - const clinicalAvailable = isIOS ? areClinicalRecordsAvailable() : false; - - return ( - - - - - - ); -} - -function StatusRow({ label, value }: { label: string; value: string }) { - return ( - - {label} - - {value} - - - ); -} - -// ── Section Header ────────────────────────────────────────────────── - -function SectionHeader({ - title, - colors, -}: { - title: string; - colors: typeof Colors.light; -}) { - return ( - {title} - ); -} - -// ── Permission Tests ──────────────────────────────────────────────── - -function PermissionTests({ - colors, - colorScheme, -}: { - colors: typeof Colors.light; - colorScheme: string | null | undefined; -}) { - const [hkResult, setHkResult] = useState({ - label: 'Request HealthKit Permissions', - status: 'idle', - }); - const [crResult, setCrResult] = useState({ - label: 'Request Clinical Records Permissions', - status: 'idle', - }); - - const handleHKPermissions = useCallback(async () => { - setHkResult((prev) => ({ ...prev, status: 'running' })); - try { - const result = await requestHealthPermissions(); - setHkResult({ - label: 'Request HealthKit Permissions', - status: result.success ? 'success' : 'error', - data: result, - error: result.success ? undefined : result.note, - }); - } catch (e) { - setHkResult({ - label: 'Request HealthKit Permissions', - status: 'error', - error: e instanceof Error ? e.message : String(e), - }); - } - }, []); - - const handleCRPermissions = useCallback(async () => { - setCrResult((prev) => ({ ...prev, status: 'running' })); - try { - const result = await requestClinicalPermissions(); - setCrResult({ - label: 'Request Clinical Records Permissions', - status: result.success ? 'success' : 'error', - data: result, - error: result.success ? undefined : result.note, - }); - } catch (e) { - setCrResult({ - label: 'Request Clinical Records Permissions', - status: 'error', - error: e instanceof Error ? e.message : String(e), - }); - } - }, []); - - return ( - - - - - ); -} - -// ── HealthKit Data Tests ──────────────────────────────────────────── - -function HealthKitTests({ - colors, - colorScheme, -}: { - colors: typeof Colors.light; - colorScheme: string | null | undefined; -}) { - const [results, setResults] = useState([ - { label: 'Daily Activity', status: 'idle' }, - { label: 'Sleep', status: 'idle' }, - { label: 'Vitals', status: 'idle' }, - ]); - - const runTest = useCallback( - async (index: number, fn: (range: DateRange) => Promise, label: string) => { - setResults((prev) => { - const next = [...prev]; - next[index] = { ...next[index], status: 'running' }; - return next; - }); - try { - const range = getLast7DaysRange(); - const data = await fn(range); - const count = Array.isArray(data) ? data.length : undefined; - setResults((prev) => { - const next = [...prev]; - next[index] = { label, status: 'success', data, count }; - return next; - }); - } catch (e) { - setResults((prev) => { - const next = [...prev]; - next[index] = { label, status: 'error', error: e instanceof Error ? e.message : String(e) }; - return next; - }); - } - }, - [], - ); - - const handleRunAll = useCallback(async () => { - await Promise.all([ - runTest(0, getDailyActivity, 'Daily Activity'), - runTest(1, getSleep, 'Sleep'), - runTest(2, getVitals, 'Vitals'), - ]); - }, [runTest]); - - return ( - - - {results.map((r, i) => ( - - ))} - - ); -} - -// ── Clinical Record Tests ─────────────────────────────────────────── - -function ClinicalRecordTests({ - colors, - colorScheme, -}: { - colors: typeof Colors.light; - colorScheme: string | null | undefined; -}) { - const [results, setResults] = useState([ - { label: 'Medications', status: 'idle' }, - { label: 'Lab Results', status: 'idle' }, - { label: 'Conditions', status: 'idle' }, - { label: 'Procedures', status: 'idle' }, - ]); - - const runTest = useCallback( - async (index: number, fn: () => Promise, label: string) => { - setResults((prev) => { - const next = [...prev]; - next[index] = { ...next[index], status: 'running' }; - return next; - }); - try { - const data = await fn(); - const count = Array.isArray(data) ? data.length : undefined; - setResults((prev) => { - const next = [...prev]; - next[index] = { label, status: 'success', data, count }; - return next; - }); - } catch (e) { - setResults((prev) => { - const next = [...prev]; - next[index] = { label, status: 'error', error: e instanceof Error ? e.message : String(e) }; - return next; - }); - } - }, - [], - ); - - const handleRunAll = useCallback(async () => { - await Promise.all([ - runTest(0, getClinicalMedications, 'Medications'), - runTest(1, getClinicalLabResults, 'Lab Results'), - runTest(2, getClinicalConditions, 'Conditions'), - runTest(3, getClinicalProcedures, 'Procedures'), - ]); - }, [runTest]); - - return ( - - - {results.map((r) => ( - - ))} - - ); -} - -// ── Shared UI Components ──────────────────────────────────────────── - -function TestButton({ - result, - onPress, - colors, - colorScheme, -}: { - result: TestResult; - onPress: () => void; - colors: typeof Colors.light; - colorScheme: string | null | undefined; -}) { - const statusIcon = - result.status === 'success' ? 'checkmark.circle.fill' : - result.status === 'error' ? 'xmark.circle.fill' : - result.status === 'running' ? 'arrow.clockwise' : - 'play.circle.fill'; - - const statusColor = - result.status === 'success' ? '#34C759' : - result.status === 'error' ? '#FF3B30' : - result.status === 'running' ? '#FF9500' : - '#007AFF'; - - return ( - - - - - {result.label} - - {result.status === 'running' && ( - - )} - - {result.error && ( - {result.error} - )} - {result.status === 'success' && result.data && ( - - {JSON.stringify(result.data)} - - )} - - ); -} - -function TestResultCard({ - result, - colors, - colorScheme, -}: { - result: TestResult; - colors: typeof Colors.light; - colorScheme: string | null | undefined; -}) { - const [expanded, setExpanded] = useState(false); - - const statusColor = - result.status === 'success' ? '#34C759' : - result.status === 'error' ? '#FF3B30' : - result.status === 'running' ? '#FF9500' : - '#8E8E93'; - - const statusText = - result.status === 'success' && result.count !== undefined - ? `${result.count} record${result.count !== 1 ? 's' : ''}` - : result.status === 'running' ? 'Fetching...' - : result.status === 'error' ? 'Error' - : 'Not run'; - - return ( - - result.data && setExpanded(!expanded)} - activeOpacity={result.data ? 0.7 : 1} - > - - - {result.label} - - {result.status === 'running' && ( - - )} - - {statusText} - - {result.data && ( - - )} - - - {result.error && ( - {result.error} - )} - - {expanded && result.data && ( - - - {truncateJSON(result.data, 2000)} - - - )} - - ); -} - -function RunAllButton({ onPress, label }: { onPress: () => void; label: string }) { - return ( - - - {label} - - ); -} - -// ── Styles ────────────────────────────────────────────────────────── - -const styles = StyleSheet.create({ - container: { flex: 1 }, - header: { paddingTop: Spacing.sm }, - scrollView: { flex: 1 }, - scrollContent: { padding: Spacing.screenHorizontal, paddingBottom: 40 }, - titleContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 10, - marginBottom: Spacing.xs, - marginTop: Spacing.md, - }, - title: { fontSize: 26, fontWeight: '700' }, - subtitle: { - fontSize: 14, - lineHeight: 20, - textAlign: 'center', - marginBottom: Spacing.lg, - }, - banner: { - borderRadius: 12, - padding: 14, - marginBottom: Spacing.lg, - gap: 8, - }, - statusRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - statusLabel: { - fontSize: 14, - color: '#8E8E93', - fontWeight: '500', - }, - statusValue: { - fontSize: 14, - fontWeight: '600', - }, - sectionHeader: { - fontSize: 18, - fontWeight: '700', - marginTop: Spacing.md, - marginBottom: Spacing.sm, - }, - testButtonContainer: { - marginBottom: 10, - }, - testButton: { - flexDirection: 'row', - alignItems: 'center', - padding: 14, - borderRadius: 12, - gap: 12, - }, - testButtonLabel: { - fontSize: 15, - fontWeight: '600', - flex: 1, - }, - resultCard: { - borderRadius: 12, - marginBottom: 8, - overflow: 'hidden', - }, - resultCardHeader: { - flexDirection: 'row', - alignItems: 'center', - padding: 14, - gap: 10, - }, - statusDot: { - width: 10, - height: 10, - borderRadius: 5, - }, - resultLabel: { - fontSize: 15, - fontWeight: '600', - flex: 1, - }, - resultStatus: { - fontSize: 13, - fontWeight: '500', - }, - errorText: { - fontSize: 12, - color: '#FF3B30', - paddingHorizontal: 14, - paddingBottom: 10, - }, - successNote: { - fontSize: 11, - color: '#34C759', - paddingHorizontal: 14, - paddingBottom: 10, - }, - jsonContainer: { - backgroundColor: '#1A1A2E', - marginHorizontal: 10, - marginBottom: 10, - borderRadius: 8, - padding: 12, - }, - jsonText: { - fontSize: 11, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - color: '#A5D6A7', - lineHeight: 16, - }, - runAllButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - backgroundColor: '#007AFF', - borderRadius: 10, - paddingVertical: 10, - marginBottom: 12, - }, - runAllLabel: { - fontSize: 14, - fontWeight: '600', - color: '#FFFFFF', - }, - footer: { - padding: Spacing.md, - paddingBottom: Spacing.lg, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: 'rgba(0,0,0,0.1)', - }, -}); diff --git a/homeflow/app/(onboarding)/ineligible.tsx b/homeflow/app/(onboarding)/ineligible.tsx index 9db356e..26f14d9 100644 --- a/homeflow/app/(onboarding)/ineligible.tsx +++ b/homeflow/app/(onboarding)/ineligible.tsx @@ -19,7 +19,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors, Spacing } from '@/constants/theme'; import { STUDY_INFO, OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; -import { ContinueButton, DevToolBar } from '@/components/onboarding'; +import { ContinueButton } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; export default function IneligibleScreen() { @@ -127,7 +127,6 @@ export default function IneligibleScreen() { /> - ); } diff --git a/homeflow/app/(onboarding)/medical-history.tsx b/homeflow/app/(onboarding)/medical-history.tsx index 2bf5979..7831cdd 100644 --- a/homeflow/app/(onboarding)/medical-history.tsx +++ b/homeflow/app/(onboarding)/medical-history.tsx @@ -33,16 +33,20 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; -import { OnboardingProgressBar, ContinueButton, DevToolBar } from '@/components/onboarding'; +import { OnboardingProgressBar, ContinueButton } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { getAllClinicalRecords } from '@/lib/services/healthkit'; import { getDemographics } from '@/lib/services/healthkit/HealthKitClient'; import { buildMedicalHistoryPrefill, type MedicalHistoryPrefill, + type LabValue, } from '@/lib/services/fhir'; import { BPH_DRUGS } from '@/lib/services/fhir/codes'; import { getMockClinicalRecords, getMockDemographics } from '@/lib/services/healthkit/mock-health-data'; +import { saveMedicalHistory } from '@/src/services/throneFirestore'; +import { getAuth } from '@/src/services/firestore'; +import { ConsentService } from '@/lib/services/consent-service'; // ── Types ───────────────────────────────────────────────────────────── @@ -59,6 +63,13 @@ const STEP_DESCRIPTIONS = [ 'Bladder and urinary function measurements.', ] as const; +const SEX_OPTIONS = [ + 'Male', + 'Female', + 'Intersex', + 'Prefer not to say', +] as const; + const ETHNICITY_OPTIONS = [ 'Hispanic or Latino', 'Not Hispanic or Latino', @@ -76,6 +87,7 @@ const RACE_OPTIONS = [ ] as const; type DemoStage = 'name' | 'ethnicity' | 'race' | 'done'; +type PickerField = 'ethnicity' | 'race' | 'biologicalSex'; // Common patient-facing names for surgical procedures, matched by keyword const PROCEDURE_COMMON_NAMES: { keywords: string[]; commonName: string }[] = [ @@ -174,6 +186,145 @@ function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } +// ── Shared sub-components (module-level to prevent remount on re-render) ────── +// +// IMPORTANT: These must live outside MedicalHistoryScreen. Defining components +// inside the parent function gives them a new reference on every render, which +// causes React to unmount/remount them (losing TextInput focus on each keystroke). + +type RowColors = { icon: string; text: string }; + +function DataRow({ + label, + value, + found, + placeholder = 'will ask', + showBadge = true, + onPress, + colors, + borderColor, +}: { + label: string; + value: string | null | undefined; + found: boolean; + placeholder?: string; + showBadge?: boolean; + onPress?: () => void; + colors: RowColors; + borderColor: string; +}) { + const inner = ( + <> + {label} + + {found && value ? ( + <> + {value} + {showBadge && ( + + Apple Health + + )} + + ) : ( + + {placeholder} + + )} + + + ); + + if (onPress) { + return ( + + {inner} + + ); + } + + return ( + + {inner} + + ); +} + +function InlineInputRow({ + label, + value, + onChange, + onSubmit, + keyboardType = 'default', + autoFocus: af = true, + placeholder = 'Type here…', + autoCapitalize = 'words', + colors, + borderColor, +}: { + label: string; + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + keyboardType?: 'default' | 'numeric' | 'number-pad'; + autoFocus?: boolean; + placeholder?: string; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + colors: RowColors; + borderColor: string; +}) { + return ( + + {label} + + + ); +} + +function SelectDataRow({ + label, + onPress, + colors, + borderColor, +}: { + label: string; + onPress: () => void; + colors: RowColors; + borderColor: string; +}) { + return ( + + {label} + + + Tap to select + + + + + ); +} + // ── Screen ──────────────────────────────────────────────────────────── export default function MedicalHistoryScreen() { @@ -189,12 +340,14 @@ export default function MedicalHistoryScreen() { // Demographics sequential input state const [demoName, setDemoName] = useState(''); + const [demoAge, setDemoAge] = useState(''); + const [demoBiologicalSex, setDemoBiologicalSex] = useState(''); const [demoEthnicity, setDemoEthnicity] = useState(''); const [demoRace, setDemoRace] = useState(''); const [demoStage, setDemoStage] = useState('name'); const [demoEditingField, setDemoEditingField] = useState<'name' | null>(null); const [pickerVisible, setPickerVisible] = useState(false); - const [pickerField, setPickerField] = useState<'ethnicity' | 'race'>('ethnicity'); + const [pickerField, setPickerField] = useState('ethnicity'); // Editable medication/procedure items (local copies initialized from prefill) const [editableMeds, setEditableMeds] = useState([]); @@ -277,6 +430,8 @@ export default function MedicalHistoryScreen() { setReviewStep(0); setCorrectionsNeeded(new Set()); setDemoName(''); + setDemoAge(''); + setDemoBiologicalSex(''); setDemoEthnicity(''); setDemoRace(''); setDemoStage('name'); @@ -291,6 +446,20 @@ export default function MedicalHistoryScreen() { loadPrefillData(); }, [loadPrefillData]); + // Pre-fill name from typed consent signature so the user doesn't enter it twice + useEffect(() => { + let cancelled = false; + ConsentService.getConsentRecord().then((record) => { + if (cancelled) return; + const sig = record?.participantSignature; + if (sig && sig !== '[Drawn signature provided]') { + setDemoName(sig); + setDemoStage('ethnicity'); + } + }).catch(() => {}); + return () => { cancelled = true; }; + }, []); + // ── Review step navigation ──────────────────────────────────────── const handleConfirmStep = useCallback((withCorrection = false) => { @@ -333,13 +502,15 @@ export default function MedicalHistoryScreen() { if (pickerField === 'ethnicity') { setDemoEthnicity(value); setDemoStage('race'); - } else { + } else if (pickerField === 'race') { setDemoRace(value); setDemoStage('done'); + } else { + setDemoBiologicalSex(value); } }, [pickerField]); - const openPicker = useCallback((field: 'ethnicity' | 'race') => { + const openPicker = useCallback((field: PickerField) => { setPickerField(field); setPickerVisible(true); }, []); @@ -395,6 +566,8 @@ export default function MedicalHistoryScreen() { const demoSummary = [ demoName && `Name: ${demoName}`, + demoAge && `Age: ${demoAge}`, + demoBiologicalSex && `Sex: ${demoBiologicalSex}`, demoEthnicity && `Ethnicity: ${demoEthnicity}`, demoRace && `Race: ${demoRace}`, ].filter(Boolean).join(', '); @@ -410,6 +583,51 @@ export default function MedicalHistoryScreen() { }, }); + // ── Write combined medical_history/current to Firestore ────────── + // User form data + FHIR prefill for fields not collected in the form + // (labs, clinical measurements, HK demographics). + const uid = getAuth().currentUser?.uid; + if (uid) { + const labEntry = (entry: { value: LabValue | null } | undefined) => + entry?.value ?? null; + + saveMedicalHistory(uid, { + demographics: { + name: demoName, + ethnicity: demoEthnicity, + race: demoRace, + age: prefillData?.demographics.age.value ?? (demoAge ? parseInt(demoAge, 10) : null), + biologicalSex: prefillData?.demographics.biologicalSex.value ?? (demoBiologicalSex || null), + dateOfBirth: null, // not exposed by HealthKit demographics API + }, + medications: editableMeds.map(m => ({ + name: m.name, + brandName: m.brandName, + groupKey: m.groupKey, + })), + surgicalHistory: editableProcs.map(p => ({ + name: p.name, + commonName: p.commonName, + date: p.date, + isBPH: p.isBPH, + })), + conditions: conditions.map(name => ({ name })), + labs: { + psa: labEntry(prefillData?.labs.psa), + hba1c: labEntry(prefillData?.labs.hba1c), + urinalysis: labEntry(prefillData?.labs.urinalysis), + }, + clinicalMeasurements: { + pvr: labEntry(prefillData?.clinicalMeasurements.pvr), + uroflowQmax: labEntry(prefillData?.clinicalMeasurements.uroflowQmax), + volumeVoided: labEntry(prefillData?.clinicalMeasurements.volumeVoided), + mobility: prefillData?.clinicalMeasurements.mobility.value ?? null, + }, + }).catch((err) => { + console.warn('[MedicalHistory] Failed to save to Firestore:', err); + }); + } + await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY); router.push('/(onboarding)/baseline-survey' as Href); }; @@ -420,118 +638,6 @@ export default function MedicalHistoryScreen() { const sectionBg = isDark ? '#2A2D2F' : '#FFFFFF'; const borderColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)'; - // ── Sub-render helpers ──────────────────────────────────────────── - - function DataRow({ - label, - value, - found, - placeholder = 'will ask', - showBadge = true, - onPress, - }: { - label: string; - value: string | null | undefined; - found: boolean; - placeholder?: string; - showBadge?: boolean; - onPress?: () => void; - }) { - const inner = ( - <> - {label} - - {found && value ? ( - <> - {value} - {showBadge && ( - - Apple Health - - )} - - ) : ( - - {placeholder} - - )} - - - ); - - if (onPress) { - return ( - - {inner} - - ); - } - - return ( - - {inner} - - ); - } - - function InlineInputRow({ - label, - value, - onChange, - onSubmit, - }: { - label: string; - value: string; - onChange: (v: string) => void; - onSubmit: () => void; - }) { - return ( - - {label} - - - ); - } - - function SelectDataRow({ - label, - onPress, - }: { - label: string; - onPress: () => void; - }) { - return ( - - {label} - - - Tap to select - - - - - ); - } - function ProcedureSection({ label, items }: { label: string; items: EditableProcItem[] }) { return ( @@ -604,21 +710,57 @@ export default function MedicalHistoryScreen() { case 0: return ( - {/* Apple Health fields — always static */} - - + {/* Age — static if from Apple Health, editable input otherwise */} + {prefillData.demographics.age.confidence !== 'none' && prefillData.demographics.age.value != null ? ( + + ) : ( + {}} + keyboardType="number-pad" + autoFocus={false} + placeholder="Enter age in years" + autoCapitalize="none" + colors={colors} + borderColor={borderColor} + /> + )} + + {/* Biological Sex — static if from Apple Health, picker otherwise */} + {prefillData.demographics.biologicalSex.confidence !== 'none' && prefillData.demographics.biologicalSex.value ? ( + + ) : demoBiologicalSex ? ( + handleFieldDoubleTap('biologicalSex', () => openPicker('biologicalSex'))} + colors={colors} + borderColor={borderColor} + /> + ) : ( + openPicker('biologicalSex')} + colors={colors} + borderColor={borderColor} + /> + )} {/* Full Name — inline input on initial entry or when re-editing */} {(demoStage === 'name' || demoEditingField === 'name') ? ( @@ -633,6 +775,8 @@ export default function MedicalHistoryScreen() { setDemoEditingField(null); } }} + colors={colors} + borderColor={borderColor} /> ) : ( handleFieldDoubleTap('name', () => setDemoEditingField('name'))} + colors={colors} + borderColor={borderColor} /> )} @@ -653,9 +799,16 @@ export default function MedicalHistoryScreen() { found showBadge={false} onPress={() => handleFieldDoubleTap('ethnicity', () => openPicker('ethnicity'))} + colors={colors} + borderColor={borderColor} /> ) : ( - openPicker('ethnicity')} /> + openPicker('ethnicity')} + colors={colors} + borderColor={borderColor} + /> ) )} @@ -668,9 +821,16 @@ export default function MedicalHistoryScreen() { found showBadge={false} onPress={() => handleFieldDoubleTap('race', () => openPicker('race'))} + colors={colors} + borderColor={borderColor} /> ) : ( - openPicker('race')} /> + openPicker('race')} + colors={colors} + borderColor={borderColor} + /> ) )} @@ -1132,7 +1292,6 @@ export default function MedicalHistoryScreen() { Looking for medications, conditions, and procedures - ); } @@ -1226,7 +1385,7 @@ export default function MedicalHistoryScreen() { )} - {/* Bottom sheet picker for Ethnicity / Race */} + {/* Bottom sheet picker for Ethnicity / Race / Biological Sex */} - {pickerField === 'ethnicity' ? 'ETHNICITY' : 'RACE'} + {pickerField === 'ethnicity' ? 'ETHNICITY' : pickerField === 'race' ? 'RACE' : 'BIOLOGICAL SEX'} - {(pickerField === 'ethnicity' ? ETHNICITY_OPTIONS : RACE_OPTIONS).map(option => { + {(pickerField === 'ethnicity' ? ETHNICITY_OPTIONS : pickerField === 'race' ? RACE_OPTIONS : SEX_OPTIONS).map(option => { const selected = pickerField === 'ethnicity' ? demoEthnicity === option - : demoRace === option; + : pickerField === 'race' + ? demoRace === option + : demoBiologicalSex === option; return ( - loadPrefillData(true), - }, - ] : undefined} - /> ); } diff --git a/homeflow/app/(onboarding)/permissions.tsx b/homeflow/app/(onboarding)/permissions.tsx index 5b6c5ee..6581fe4 100644 --- a/homeflow/app/(onboarding)/permissions.tsx +++ b/homeflow/app/(onboarding)/permissions.tsx @@ -15,6 +15,12 @@ import { Platform, Alert, Linking, + Modal, + TextInput, + KeyboardAvoidingView, + Pressable, + TouchableOpacity, + ActivityIndicator, } from 'react-native'; import { useRouter, Href } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -33,14 +39,17 @@ import { PermissionCard, ContinueButton, PermissionStatus, - DevToolBar, } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; +import { useAuth } from '@/lib/auth/auth-context'; +import { saveThroneUserId } from '@/src/services/throneFirestore'; export default function PermissionsScreen() { const router = useRouter(); const colorScheme = useColorScheme(); + const isDark = colorScheme === 'dark'; const colors = Colors[colorScheme ?? 'light']; + const { user } = useAuth(); const [healthKitStatus, setHealthKitStatus] = useState('not_determined'); const [throneStatus, setThroneStatus] = useState('not_determined'); @@ -48,6 +57,11 @@ export default function PermissionsScreen() { const [clinicalAvailable, setClinicalAvailable] = useState(false); const [isLoading, setIsLoading] = useState(false); + // Throne User ID modal state + const [throneModalVisible, setThroneModalVisible] = useState(false); + const [throneIdInput, setThroneIdInput] = useState(''); + const [throneIdSaving, setThroneIdSaving] = useState(false); + // HealthKit is required, Throne is optional const canContinue = healthKitStatus === 'granted' || Platform.OS !== 'ios'; @@ -119,15 +133,44 @@ export default function PermissionsScreen() { setClinicalStatus('skipped'); }, []); - const handleThroneRequest = useCallback(async () => { - setThroneStatus('loading'); + // Opens the Throne User ID modal instead of immediately calling the service + const handleThroneRequest = useCallback(() => { + setThroneIdInput(''); + setThroneModalVisible(true); + }, []); + + // Called when the user confirms their Throne User ID in the modal + const handleThroneIdConfirm = useCallback(async () => { + const trimmed = throneIdInput.trim(); + if (!trimmed) return; + + setThroneIdSaving(true); try { - const status = await ThroneService.requestPermission(); - setThroneStatus(status); + const uid = user?.id; + if (uid) { + // SHORT-TERM: User manually enters their Throne User ID (found in the + // Throne app under Profile → Account). We persist it here so the + // syncThroneUserMap Cloud Function trigger automatically creates the + // throneUserMap reverse-lookup entry for data ingestion. + await saveThroneUserId(uid, trimmed); + } + + // LONG-TERM (uncomment when real Throne SDK is available): + // The OAuth flow will return the throneUserId automatically — no manual + // entry needed. Replace the saveThroneUserId call above with: + // + // const throneResult = await ThroneSDK.authorize({ studyId: THRONE_STUDY_ID }); + // if (uid) await saveThroneUserId(uid, throneResult.userId); + + await ThroneService.requestPermission(); + setThroneStatus('granted'); + setThroneModalVisible(false); } catch { - setThroneStatus('denied'); + Alert.alert('Error', 'Failed to save Throne User ID. Please try again.'); + } finally { + setThroneIdSaving(false); } - }, []); + }, [throneIdInput, user?.id]); const handleThroneSkip = useCallback(async () => { await ThroneService.skipSetup(); @@ -147,8 +190,8 @@ export default function PermissionsScreen() { throne: throneStatus as 'granted' | 'denied' | 'not_determined' | 'skipped', }, }); - await OnboardingService.goToStep(OnboardingStep.HEALTH_DATA_TEST); - router.push('/(onboarding)/health-data-test' as Href); + await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); + router.push('/(onboarding)/medical-history' as Href); } finally { setIsLoading(false); } @@ -202,13 +245,12 @@ export default function PermissionsScreen() { - { - setHealthKitStatus('not_determined'); - setThroneStatus('not_determined'); - Alert.alert( - 'Permissions Reset', - 'App permission state cleared. Tap "Request HealthKit Access" to re-request.\n\nNote: iOS only shows the system dialog once per install. To fully reset, delete and reinstall the app.', - ); - }, - }, - ]} - /> + {/* Throne User ID Modal */} + setThroneModalVisible(false)} + > + setThroneModalVisible(false)} + > + + + + + + + + + + Connect Throne Device + + + Enter your Throne User ID. You can find this in the Throne app under{' '} + Profile → Account → User ID. + + + + + + {throneIdSaving ? ( + + ) : ( + + Connect + + )} + + + setThroneModalVisible(false)} + disabled={throneIdSaving} + > + Cancel + + + + + + ); } @@ -302,3 +404,72 @@ const styles = StyleSheet.create({ marginBottom: Spacing.sm, }, }); + +const modalStyles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.45)', + justifyContent: 'flex-end', + }, + sheet: { + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 24, + paddingBottom: 40, + alignItems: 'center', + }, + handle: { + width: 36, + height: 4, + borderRadius: 2, + marginBottom: 20, + }, + iconRow: { + width: 56, + height: 56, + borderRadius: 14, + backgroundColor: 'rgba(140,21,21,0.1)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + title: { + fontSize: 18, + fontWeight: '700', + marginBottom: 8, + textAlign: 'center', + }, + body: { + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + marginBottom: 20, + }, + input: { + width: '100%', + height: 48, + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 14, + fontSize: 15, + marginBottom: 16, + }, + confirmButton: { + width: '100%', + height: 50, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 10, + }, + confirmText: { + fontSize: 16, + fontWeight: '600', + }, + cancelButton: { + paddingVertical: 10, + }, + cancelText: { + fontSize: 15, + }, +}); diff --git a/homeflow/app/(onboarding)/welcome.tsx b/homeflow/app/(onboarding)/welcome.tsx index 328834d..ee4a06d 100644 --- a/homeflow/app/(onboarding)/welcome.tsx +++ b/homeflow/app/(onboarding)/welcome.tsx @@ -18,7 +18,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { STUDY_INFO, OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; -import { ContinueButton, DevToolBar } from '@/components/onboarding'; +import { ContinueButton } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { FontSize, FontWeight } from '@/lib/theme/typography'; @@ -136,7 +136,6 @@ export default function WelcomeScreen() { - ); } diff --git a/homeflow/app/(tabs)/index.tsx b/homeflow/app/(tabs)/index.tsx index 8bcadc7..1e4ed9f 100644 --- a/homeflow/app/(tabs)/index.tsx +++ b/homeflow/app/(tabs)/index.tsx @@ -25,14 +25,15 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router } from 'expo-router'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { IconSymbol } from '@/components/ui/icon-symbol'; +import { STORAGE_KEYS, DEV_FIREBASE_UID } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; import { notifyOnboardingComplete } from '@/hooks/use-onboarding-status'; import { useSurgeryDate } from '@/hooks/use-surgery-date'; import { useWatchUsage } from '@/hooks/use-watch-usage'; import { SurgeryCompleteModal } from '@/components/home/SurgeryCompleteModal'; import { useAuth } from '@/lib/auth/auth-context'; -import { useThroneUserId } from '@/hooks/use-throne-user-id'; import { useAppTheme } from '@/lib/theme/ThemeContext'; import { FontSize, FontWeight } from '@/lib/theme/typography'; import { useHealthSummary } from '@/hooks/use-health-summary'; @@ -417,8 +418,7 @@ export default function HomeScreen() { const { theme } = useAppTheme(); const { colors: t } = theme; const { user } = useAuth(); - const uid = user?.id ?? null; - const { throneUserId } = useThroneUserId(uid); + const uid = user?.id ?? (__DEV__ ? DEV_FIREBASE_UID : null); const surgery = useSurgeryDate(); const watch = useWatchUsage(); @@ -427,6 +427,21 @@ export default function HomeScreen() { const [watchDismissed, setWatchDismissed] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + // ─── Auto-show Surgery Complete modal (once, when surgery date first passes) ─ + useEffect(() => { + if (surgery.isLoading || surgery.isPlaceholder || !surgery.hasPassed) return; + + async function maybeShowModal() { + const already = await AsyncStorage.getItem(STORAGE_KEYS.SURGERY_MODAL_SHOWN); + if (!already) { + await AsyncStorage.setItem(STORAGE_KEYS.SURGERY_MODAL_SHOWN, 'true'); + setShowSurgeryModal(true); + } + } + + maybeShowModal(); + }, [surgery.isLoading, surgery.isPlaceholder, surgery.hasPassed]); + // ─── Throne data ─────────────────────────────────────────────────────────── const [sessions, setSessions] = useState([]); @@ -440,11 +455,12 @@ export default function HomeScreen() { setThroneLoading(true); try { const since = new Date(Date.now() - WEEK_MS); - const raw: ThroneSession[] = await fetchSessions({ startDate: since, userId: throneUserId ?? undefined }); + if (!uid) return; + const raw: ThroneSession[] = await fetchSessions(uid, { startDate: since }); if (cancelled) return; const ids = raw.map((s) => s.id); - const allMetrics: ThroneMetric[] = await fetchMetricsBatch(ids); + const allMetrics: ThroneMetric[] = await fetchMetricsBatch(uid, ids); if (cancelled) return; // Build sessionId → ThroneMetric[] map (same as voiding.tsx) @@ -469,7 +485,7 @@ export default function HomeScreen() { loadThroneData(); return () => { cancelled = true; }; - }, [refreshKey, throneUserId]); + }, [refreshKey, uid]); const weekSessions = useMemo( () => filterByRange(sessions, '1w'), @@ -597,13 +613,6 @@ export default function HomeScreen() { Surgery date - {surgery.isPlaceholder && __DEV__ && ( - - - Placeholder - - - )} {surgery.isLoading ? ( Loading... @@ -632,13 +641,6 @@ export default function HomeScreen() { Study timeline - {surgery.isPlaceholder && __DEV__ && ( - - - Placeholder - - - )} @@ -1013,8 +1015,6 @@ const styles = StyleSheet.create({ }, cardValue: { fontSize: FontSize.titleMedium, fontWeight: FontWeight.bold, letterSpacing: 0.35 }, cardSubtext: { fontSize: FontSize.subhead, fontWeight: FontWeight.regular, marginTop: 4 }, - placeholderBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, marginLeft: 'auto' }, - placeholderBadgeText: { fontSize: FontSize.micro, fontWeight: FontWeight.medium }, timelineRow: { flexDirection: 'row', alignItems: 'center' }, timelineItem: { flex: 1 }, timelineLabel: { fontSize: FontSize.footnote, fontWeight: FontWeight.regular, marginBottom: 2 }, diff --git a/homeflow/app/(tabs)/profile.tsx b/homeflow/app/(tabs)/profile.tsx index 9f87823..69d144e 100644 --- a/homeflow/app/(tabs)/profile.tsx +++ b/homeflow/app/(tabs)/profile.tsx @@ -14,7 +14,6 @@ import { useRouter, Href } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { useAuth } from '@/hooks/use-auth'; -import { triggerTestNotification, requestNotificationPermissions } from '@/lib/services/notification-service'; import { CONSENT_PROFILE_SUMMARY, DATA_PERMISSIONS_SUMMARY, @@ -210,82 +209,6 @@ export default function ProfileScreen() { - {/* Developer menu — visible in __DEV__ builds only */} - {__DEV__ && ( - - - - - Developer - - - - router.push('/fhir-parser-test' as Href)} - activeOpacity={0.7} - > - - - - FHIR Parser Test - - - - - - - - { - try { - const granted = await requestNotificationPermissions(); - if (!granted) { - Alert.alert('Permission Denied', 'Enable notifications in Settings → HomeFlow → Notifications.'); - return; - } - await triggerTestNotification('healthkit'); - Alert.alert('Sent', 'HealthKit reminder notification fired. Background the app to see the banner.'); - } catch (e: any) { - Alert.alert('Error', e?.message ?? 'Failed to send notification.'); - } - }} - activeOpacity={0.7} - > - - - Test HealthKit Reminder - - - - - - { - try { - const granted = await requestNotificationPermissions(); - if (!granted) { - Alert.alert('Permission Denied', 'Enable notifications in Settings → HomeFlow → Notifications.'); - return; - } - await triggerTestNotification('throne'); - Alert.alert('Sent', 'Throne reminder notification fired. Background the app to see the banner.'); - } catch (e: any) { - Alert.alert('Error', e?.message ?? 'Failed to send notification.'); - } - }} - activeOpacity={0.7} - > - - - Test Throne Reminder - - - - )} - {/* Sign Out */} >(new Set()); // Persisted logs: YYYY-MM-DD → array of symptom ids @@ -225,19 +223,19 @@ export default function TrackerScreen() { .filter(Boolean); }, [historyDay, logs]); - const borderColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.07)'; - const sheetBg = isDark ? '#1C1C1E' : '#FFFFFF'; - const calCellBg = isDark ? '#2C2C2E' : '#F2F2F7'; + const borderColor = c.separator; + const sheetBg = c.card; + const calCellBg = c.secondaryFill; return ( - + {/* ── Header ── */} - + {/* Date block */} - {weekday} - {monthDay} + {weekday} + {monthDay} {/* Right-side controls */} @@ -255,12 +253,12 @@ export default function TrackerScreen() { style={styles.calendarButton} activeOpacity={0.7} > - + - + Track your symptoms to monitor how you're feeling @@ -274,9 +272,9 @@ export default function TrackerScreen() { const selectedCount = category.symptoms.filter(s => selected.has(s.id)).length; return ( - + {category.label}{' '} - + {selectedCount}/{category.symptoms.length} @@ -311,7 +309,7 @@ export default function TrackerScreen() { )} @@ -329,7 +327,7 @@ export default function TrackerScreen() { {/* ── Save button ── */} - + Save @@ -353,9 +351,9 @@ export default function TrackerScreen() { {/* Month navigation */} - + - + {calMonthLabel} @@ -375,7 +373,7 @@ export default function TrackerScreen() { {/* Day-of-week headers */} {DAY_LABELS.map(d => ( - {d} + {d} ))} @@ -413,7 +411,7 @@ export default function TrackerScreen() { ]}> {date.getDate()} @@ -430,7 +428,7 @@ export default function TrackerScreen() { {/* Day detail — slides in when a logged day is tapped */} {historyDay && ( - + {(() => { const d = new Date(historyDay + 'T12:00:00'); const { weekday: wd, monthDay: md } = formatDayHeading(d); @@ -438,7 +436,7 @@ export default function TrackerScreen() { })()} {historySymptoms.length === 0 ? ( - + No symptoms logged ) : ( diff --git a/homeflow/app/(tabs)/voiding.tsx b/homeflow/app/(tabs)/voiding.tsx index 1cba32f..4c76d86 100644 --- a/homeflow/app/(tabs)/voiding.tsx +++ b/homeflow/app/(tabs)/voiding.tsx @@ -32,7 +32,7 @@ import { router } from 'expo-router'; import { useAppTheme } from '@/lib/theme/ThemeContext'; import { FontSize, FontWeight } from '@/lib/theme/typography'; import { useAuth } from '@/lib/auth/auth-context'; -import { useThroneUserId } from '@/hooks/use-throne-user-id'; +import { DEV_FIREBASE_UID } from '@/lib/constants'; import { fetchSessions, fetchMetricsBatch, @@ -543,8 +543,7 @@ export default function VoidingScreen() { const { theme } = useAppTheme(); const { isDark, colors: c } = theme; const { user } = useAuth(); - const uid = user?.id ?? null; - const { throneUserId } = useThroneUserId(uid); + const uid = user?.id ?? (__DEV__ ? DEV_FIREBASE_UID : null); // ── UI state ────────────────────────────────────────────────────────────── const [range, setRange] = useState('1w'); @@ -595,14 +594,15 @@ export default function VoidingScreen() { async function load() { try { const startDate = new Date(Date.now() - NINETY_DAYS_MS); - const data = await fetchSessions({ startDate, userId: throneUserId ?? undefined }); + if (!uid) return; + const data = await fetchSessions(uid, { startDate }); if (cancelled) return; setAllSessions(data); // Batch-fetch metrics for all sessions if (data.length > 0) { const ids = data.map(s => s.id); - const metrics = await fetchMetricsBatch(ids); + const metrics = await fetchMetricsBatch(uid, ids); if (cancelled) return; // Build a Map: sessionId → ThroneMetric[] @@ -624,7 +624,7 @@ export default function VoidingScreen() { load(); return () => { cancelled = true; }; - }, [refreshKey, throneUserId]); + }, [refreshKey, uid]); // ─── Pull-to-refresh ────────────────────────────────────────────────────── const handleRefresh = useCallback(() => { diff --git a/homeflow/app/_layout.tsx b/homeflow/app/_layout.tsx index 2fd7a5d..deb5059 100644 --- a/homeflow/app/_layout.tsx +++ b/homeflow/app/_layout.tsx @@ -1,13 +1,10 @@ -import React, { useEffect, useRef } from 'react'; -import { Platform } from 'react-native'; +import React, { useEffect } from 'react'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; // Global CSS for web (theming for alert dialogs, etc.) - only processed on web import '@/assets/styles/global.css'; -import { doc, setDoc, serverTimestamp } from 'firebase/firestore'; -import { db } from '@/src/services/firebase'; import { bootstrapHealthKitSync } from '@/src/services/healthkitSync'; import { useOnboardingStatus } from '@/hooks/use-onboarding-status'; @@ -18,6 +15,10 @@ import { ErrorBoundary } from '@/components/error-boundary'; import { StandardProvider, useStandard } from '@/lib/services/standard-context'; import { AppThemeProvider, useAppTheme } from '@/lib/theme/ThemeContext'; +// Module-level guard — survives Fast Refresh hot reloads (unlike useRef). +// Prevents bootstrapHealthKitSync from firing more than once per UID per process. +const _bootstrappedUids = new Set(); + export const unstable_settings = { // Initial route while loading initialRouteName: 'index', @@ -35,29 +36,23 @@ function RootLayoutNav() { const { isAuthenticated, isLoading: authLoading, user } = useAuth(); // Run bootstrapHealthKitSync exactly once per signed-in uid. - // A ref guard prevents re-firing on re-renders or context refreshes. - const lastBootstrappedUid = useRef(null); - + // Module-level Set survives Fast Refresh; a ref would reset on every hot reload. useEffect(() => { const uid = user?.id; if (!uid) return; - if (lastBootstrappedUid.current === uid) return; - lastBootstrappedUid.current = uid; - - // Guaranteed debug write — confirms the app can reach Firestore. - const pingPath = `debug/ping-${uid}`; - setDoc(doc(db, pingPath), { - ok: true, - ts: serverTimestamp(), - platform: Platform.OS, - }) - .then(() => console.log(`[Firebase] Wrote debug ping: ${pingPath}`)) - .catch((err) => console.error("[Firebase] Debug ping write failed:", err)); - - // Bootstrap HealthKit → Firestore sync (fire-and-forget; never throws). - bootstrapHealthKitSync().catch((err) => - console.error("[HealthKit] bootstrapHealthKitSync error:", err), - ); + if (_bootstrappedUids.has(uid)) return; + _bootstrappedUids.add(uid); + + // Delay 4 s so the home screen's HealthKit queries (12 parallel reads) complete + // first. HealthKit serializes concurrent queries — without the delay the sync + // pipeline backs them up and the UI feels frozen on first load. + const timer = setTimeout(() => { + bootstrapHealthKitSync().catch((err) => + console.error("[HealthKit] bootstrapHealthKitSync error:", err), + ); + }, 4000); + + return () => clearTimeout(timer); }, [user?.id]); // Run 48-hour data sync check only when user is fully in the app @@ -120,12 +115,6 @@ function RootLayoutNav() { options={{ headerShown: false }} /> - {/* Dev-only: FHIR parser test screen (accessible from Profile > Developer) */} - - {/* Index route for initial redirect */} e?.resource).filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (r: any): r is Record => - r != null && typeof r.resourceType === 'string', - ) - ); - } - // Alternate: { resources: [...] } - if (Array.isArray(raw?.resources)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (raw.resources as any[]).filter(Boolean); - } - // Single resource - if (raw?.resourceType) return [raw]; - return []; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers: extract display name from a raw FHIR resource -// ───────────────────────────────────────────────────────────────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function rawDisplayName(resource: any): string { - const rt: string = resource.resourceType ?? ''; - if ( - rt === 'MedicationRequest' || - rt === 'MedicationOrder' || - rt === 'MedicationStatement' - ) { - const cc = resource.medicationCodeableConcept ?? resource.medication; - if (cc?.text) return cc.text as string; - if (Array.isArray(cc?.coding) && cc.coding[0]?.display) - return cc.coding[0].display as string; - if (resource.medicationReference?.display) - return resource.medicationReference.display as string; - return rt; - } - const cc = resource.code; - if (cc?.text) return cc.text as string; - if (Array.isArray(cc?.coding) && cc.coding[0]?.display) - return cc.coding[0].display as string; - return rt; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Build ClinicalRecordsInput from raw bundle resources -// ───────────────────────────────────────────────────────────────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function buildClinicalInput(resources: any[]): ClinicalRecordsInput { - const medications: ClinicalRecordsInput['medications'] = []; - const labResults: ClinicalRecordsInput['labResults'] = []; - const conditions: ClinicalRecordsInput['conditions'] = []; - const procedures: ClinicalRecordsInput['procedures'] = []; - - for (const r of resources) { - const displayName = rawDisplayName(r); - const fhirResource = r as Record; - switch (r.resourceType as string) { - case 'MedicationRequest': - case 'MedicationOrder': - case 'MedicationStatement': - medications.push({ displayName, fhirResource }); - break; - case 'Observation': - case 'DiagnosticReport': - labResults.push({ displayName, fhirResource }); - break; - case 'Condition': - conditions.push({ displayName, fhirResource }); - break; - case 'Procedure': - procedures.push({ displayName, fhirResource }); - break; - } - } - - return { medications, labResults, conditions, procedures }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Extract HealthKitDemographics from the Patient resource -// ───────────────────────────────────────────────────────────────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractDemographics(resources: any[]): HealthKitDemographics | null { - const patient = resources.find((r) => r.resourceType === 'Patient'); - if (!patient) return null; - - let age: number | null = null; - const birthDate: string | null = - typeof patient.birthDate === 'string' ? patient.birthDate : null; - - if (birthDate) { - const born = new Date(birthDate); - const now = new Date(); - age = now.getFullYear() - born.getFullYear(); - const m = now.getMonth() - born.getMonth(); - if (m < 0 || (m === 0 && now.getDate() < born.getDate())) age -= 1; - } - - const biologicalSex: string | null = - typeof patient.gender === 'string' ? patient.gender : null; - - return { age, dateOfBirth: birthDate, biologicalSex }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Run the parser against a fixture -// ───────────────────────────────────────────────────────────────────────────── - -interface ParseResult { - prefill: MedicalHistoryPrefill; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rawResources: any[]; - resourceCount: number; - durationMs: number; -} - -function runParser(key: FixtureKey): ParseResult { - const raw = FIXTURE_MAP[key]; - const resources = normalizeToResources(raw); - const clinicalInput = buildClinicalInput(resources); - const demographics = extractDemographics(resources); - const t0 = Date.now(); - const prefill = buildMedicalHistoryPrefill(clinicalInput, demographics); - return { - prefill, - rawResources: resources, - resourceCount: resources.length, - durationMs: Date.now() - t0, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shared sub-components -// ───────────────────────────────────────────────────────────────────────────── - -// ── Confidence badge ────────────────────────────────────────────────────────── - -const CONF_COLOR: Record = { - high: '#30D158', - medium: '#FF9F0A', - low: '#FF453A', - none: '#636366', -}; - -function ConfidenceBadge({ confidence }: { confidence: string }) { - const color = CONF_COLOR[confidence] ?? '#636366'; - return ( - - {confidence} - - ); -} - -const badgeS = StyleSheet.create({ - pill: { paddingHorizontal: 7, paddingVertical: 2, borderRadius: 8 }, - text: { - fontSize: 10, - fontWeight: '700', - textTransform: 'uppercase', - letterSpacing: 0.4, - }, -}); - -// ── Prefill row ─────────────────────────────────────────────────────────────── - -function PrefillRow({ - label, - entry, - renderValue, -}: { - label: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - entry: PrefillEntry; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - renderValue?: (v: any) => string; -}) { - const { theme } = useAppTheme(); - const c = theme.colors; - const hasValue = entry.value != null; - const displayValue = hasValue - ? renderValue - ? renderValue(entry.value) - : String(entry.value) - : '—'; - - return ( - - {label} - - - {displayValue} - - - - - ); -} - -const rowS = StyleSheet.create({ - row: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - paddingVertical: 7, - gap: 8, - }, - label: { fontSize: 14, flex: 1, paddingTop: 1 }, - right: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - flexShrink: 1, - maxWidth: '65%', - }, - value: { fontSize: 14, fontWeight: '500', textAlign: 'right', flexShrink: 1 }, - empty: { fontStyle: 'italic' }, -}); - -// ── Section card ────────────────────────────────────────────────────────────── - -function SectionCard({ - title, - icon, - children, -}: { - title: string; - icon: string; - children: React.ReactNode; -}) { - const { theme } = useAppTheme(); - const c = theme.colors; - return ( - - - - {title} - - - {children} - - ); -} - -const sCardS = StyleSheet.create({ - card: { borderRadius: 14, padding: 16, marginBottom: 12 }, - header: { flexDirection: 'row', alignItems: 'center', gap: 7, marginBottom: 10 }, - title: { - fontSize: 12, - fontWeight: '700', - letterSpacing: 0.5, - textTransform: 'uppercase', - }, - divider: { height: StyleSheet.hairlineWidth, marginBottom: 6 }, -}); - -// ── Collapsible JSON viewer ─────────────────────────────────────────────────── - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function JsonViewer({ data }: { data: any }) { - const [expanded, setExpanded] = useState(false); - const { theme } = useAppTheme(); - const c = theme.colors; - const json = JSON.stringify(data, null, 2); - const count = Array.isArray(data) ? (data as unknown[]).length : null; - - return ( - - setExpanded((v) => !v)} - activeOpacity={0.7} - > - - - {expanded ? 'Hide Raw FHIR Resources' : 'Show Raw FHIR Resources'} - - {count != null && ( - - {count} resource{count !== 1 ? 's' : ''} - - )} - - - {expanded && ( - - {json} - - )} - - ); -} - -const jsonS = StyleSheet.create({ - card: { borderRadius: 14, padding: 16, marginBottom: 12 }, - toggleRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, - toggleText: { fontSize: 15, fontWeight: '500', flex: 1 }, - count: { fontSize: 12 }, - scrollBox: { - maxHeight: 380, - marginTop: 12, - backgroundColor: '#1A1A2E', - borderRadius: 10, - padding: 12, - }, - jsonText: { - fontSize: 11, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - color: '#A5D6A7', - lineHeight: 16, - }, -}); - -// ───────────────────────────────────────────────────────────────────────────── -// State type -// ───────────────────────────────────────────────────────────────────────────── - -type RunState = - | { status: 'idle' } - | { status: 'success'; result: ParseResult } - | { status: 'error'; message: string }; - -// ───────────────────────────────────────────────────────────────────────────── -// Main screen -// ───────────────────────────────────────────────────────────────────────────── - -export default function FhirParserTestScreen() { - const { theme } = useAppTheme(); - const c = theme.colors; - const router = useRouter(); - - const [selectedFixture, setSelectedFixture] = useState(FIXTURE_KEYS[0]); - const [runState, setRunState] = useState({ status: 'idle' }); - - // ── Run parser ────────────────────────────────────────────────────── - const handleRun = useCallback(() => { - try { - const result = runParser(selectedFixture); - setRunState({ status: 'success', result }); - } catch (err) { - setRunState({ - status: 'error', - message: err instanceof Error ? err.message : String(err), - }); - } - }, [selectedFixture]); - - // ── Copy output JSON ──────────────────────────────────────────────── - const handleCopy = useCallback(async () => { - if (runState.status !== 'success') return; - const json = JSON.stringify(runState.result.prefill, null, 2); - try { - // Use expo-clipboard when available (npx expo install expo-clipboard). - // require() avoids TS module-resolution errors for optional packages. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const Clipboard = require('expo-clipboard'); - await (Clipboard.setStringAsync as (s: string) => Promise)(json); - Alert.alert('Copied', 'Prefill output JSON copied to clipboard.'); - } catch { - // Fallback: native Share sheet (always available) - try { - await Share.share({ message: json }); - } catch { - Alert.alert('Error', 'Could not copy or share output JSON.'); - } - } - }, [runState]); - - // Production guard — after all hooks to satisfy Rules of Hooks - if (!__DEV__) return null; - - const prefill = runState.status === 'success' ? runState.result.prefill : null; - const rawResources = runState.status === 'success' ? runState.result.rawResources : []; - - return ( - - {/* ── Navigation bar ── */} - - router.back()} - style={mainS.backBtn} - activeOpacity={0.7} - > - - Back - - FHIR Parser Test - {/* Balance spacer */} - - - - - {/* ── Dev banner ── */} - - - - DEV ONLY — not visible in production builds - - - - {/* ── Fixture selector ── */} - - - - - Select Fixture - - - - {FIXTURE_KEYS.map((key) => { - const active = selectedFixture === key; - return ( - setSelectedFixture(key)} - activeOpacity={0.7} - > - - {active && ( - - )} - - - {key} - - - ); - })} - - {/* Run button */} - - - Run Parser - - - {/* Status */} - {runState.status === 'success' && ( - - - - Parsed in {runState.result.durationMs} ms ·{' '} - {runState.result.resourceCount} resources - - - )} - {runState.status === 'error' && ( - <> - - - - Parse error - - - {runState.message} - - )} - - - {/* ── Prefill output sections ── */} - {prefill && ( - <> - {/* 1. Demographics */} - - - `${v} yrs`} - /> - - - - - - {/* 2. Medications */} - - v.map((m: { name: string }) => m.name).join(', ')} - /> - v.map((m: { name: string }) => m.name).join(', ')} - /> - v.map((m: { name: string }) => m.name).join(', ')} - /> - v.map((m: { name: string }) => m.name).join(', ')} - /> - v.map((m: { name: string }) => m.name).join(', ')} - /> - - - {/* 3. Surgical History */} - - v.map((p: { name: string }) => p.name).join(', ')} - /> - - `${v.length} procedure${v.length !== 1 ? 's' : ''}` - } - /> - - - {/* 4. Labs */} - - - `${v.value} ${v.unit} (${(v.date as string).slice(0, 10)})` - } - /> - - `${v.value}${v.unit} (${(v.date as string).slice(0, 10)})` - } - /> - - `${v.value} ${v.unit} (${(v.date as string).slice(0, 10)})` - } - /> - - - {/* 5. Conditions */} - - - v.map((cond: { name: string }) => cond.name).join(', ') - } - /> - - v.map((cond: { name: string }) => cond.name).join(', ') - } - /> - - v.map((cond: { name: string }) => cond.name).join(', ') - } - /> - - `${v.length} condition${v.length !== 1 ? 's' : ''}` - } - /> - - - {/* 6. Clinical Measurements */} - - `${v.value} ${v.unit}`} - /> - `${v.value} ${v.unit}`} - /> - - - - {/* 7. Upcoming Surgery */} - - - - - - {/* Copy JSON button */} - - - - Copy Output JSON - - - - {/* Collapsible raw FHIR viewer */} - - - )} - - - - - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Styles -// ───────────────────────────────────────────────────────────────────────────── - -const mainS = StyleSheet.create({ - container: { flex: 1 }, - - // Nav bar - navBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - backBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, minWidth: 60 }, - backText: { fontSize: 17 }, - navTitle: { fontSize: 17, fontWeight: '600' }, - - // Dev banner - devBanner: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 6, - backgroundColor: '#FF9F0A22', - borderRadius: 10, - paddingVertical: 8, - marginBottom: 14, - }, - devBannerText: { - fontSize: 12, - fontWeight: '600', - color: '#FF9F0A', - letterSpacing: 0.2, - }, - - scroll: { flex: 1 }, - scrollContent: { paddingHorizontal: 16, paddingTop: 12 }, - - // Fixture card - card: { borderRadius: 14, padding: 16, marginBottom: 12 }, - cardHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - marginBottom: 12, - }, - cardLabel: { - fontSize: 12, - fontWeight: '700', - letterSpacing: 0.5, - textTransform: 'uppercase', - }, - - // Fixture picker rows - fixtureRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 12, - paddingVertical: 10, - paddingHorizontal: 12, - borderRadius: 10, - borderWidth: 1.5, - marginBottom: 8, - }, - radio: { - width: 20, - height: 20, - borderRadius: 10, - borderWidth: 2, - alignItems: 'center', - justifyContent: 'center', - }, - radioDot: { width: 10, height: 10, borderRadius: 5 }, - fixtureLabel: { fontSize: 15, fontWeight: '500' }, - - // Run button - runBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - paddingVertical: 13, - borderRadius: 12, - marginTop: 8, - }, - runBtnText: { fontSize: 16, fontWeight: '600', color: '#FFF' }, - - // Status - statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 10 }, - statusText: { fontSize: 13, fontWeight: '500' }, - errorDetail: { fontSize: 12, color: '#FF453A', marginTop: 4 }, - - // Copy button - copyBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - borderRadius: 14, - paddingVertical: 13, - marginBottom: 12, - }, - copyBtnText: { fontSize: 15, fontWeight: '600' }, -}); diff --git a/homeflow/app/throne-session.tsx b/homeflow/app/throne-session.tsx index efcc6ce..a60bc6f 100644 --- a/homeflow/app/throne-session.tsx +++ b/homeflow/app/throne-session.tsx @@ -19,6 +19,8 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, router } from 'expo-router'; +import { useAuth } from '@/lib/auth/auth-context'; +import { DEV_FIREBASE_UID } from '@/lib/constants'; import { useAppTheme } from '@/lib/theme/ThemeContext'; import { FontSize, FontWeight } from '@/lib/theme/typography'; import { @@ -216,6 +218,8 @@ export default function ThroneSessionDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const { theme } = useAppTheme(); const { isDark, colors: c } = theme; + const { user } = useAuth(); + const uid = user?.id ?? (__DEV__ ? DEV_FIREBASE_UID : null); const [session, setSession] = useState(); const [sessionMetrics, setSessionMetrics] = useState([]); @@ -225,11 +229,11 @@ export default function ThroneSessionDetailScreen() { let cancelled = false; async function load() { - if (!id) return; + if (!id || !uid) return; try { const [allSessions, metrics] = await Promise.all([ - fetchSessions(), - fetchMetricsForSession(id), + fetchSessions(uid), + fetchMetricsForSession(uid, id), ]); if (!cancelled) { setSession(allSessions.find((s) => s.id === id)); @@ -242,7 +246,7 @@ export default function ThroneSessionDetailScreen() { load(); return () => { cancelled = true; }; - }, [id]); + }, [id, uid]); // Extract summary stats const stats = useMemo(() => { diff --git a/homeflow/components/onboarding/DevToolBar.tsx b/homeflow/components/onboarding/DevToolBar.tsx deleted file mode 100644 index 249fc3b..0000000 --- a/homeflow/components/onboarding/DevToolBar.tsx +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Dev Tool Bar - * - * Development-only navigation bar for iterating through onboarding screens. - * Provides Reset (red), Prev (yellow), and Continue (green) buttons. - * - * TODO: Remove before production release. - */ - -import React from 'react'; -import { View, TouchableOpacity, Text, StyleSheet } from 'react-native'; -import { useRouter, Href } from 'expo-router'; -import { OnboardingStep, ONBOARDING_FLOW } from '@/lib/constants'; -import { OnboardingService } from '@/lib/services/onboarding-service'; - -const STEP_TO_PATH: Record = { - [OnboardingStep.WELCOME]: '/(onboarding)/welcome', - [OnboardingStep.CHAT]: '/(onboarding)/chat', - [OnboardingStep.CONSENT]: '/(onboarding)/consent', - [OnboardingStep.ACCOUNT]: '/(onboarding)/account', - [OnboardingStep.PERMISSIONS]: '/(onboarding)/permissions', - [OnboardingStep.HEALTH_DATA_TEST]: '/(onboarding)/health-data-test', - [OnboardingStep.MEDICAL_HISTORY]: '/(onboarding)/medical-history', - [OnboardingStep.BASELINE_SURVEY]: '/(onboarding)/baseline-survey', - [OnboardingStep.COMPLETE]: '/(onboarding)/complete', -}; - -interface DevToolBarProps { - currentStep: OnboardingStep; - onContinue: () => void; - extraActions?: { label: string; onPress: () => void; color?: string }[]; -} - -export function DevToolBar({ currentStep, onContinue, extraActions }: DevToolBarProps) { - const router = useRouter(); - - // Only render in development - if (!__DEV__) return null; - - const currentIndex = ONBOARDING_FLOW.indexOf(currentStep); - const hasPrev = currentIndex > 0; - const hasNext = currentIndex < ONBOARDING_FLOW.length - 1; - - const handleReset = async () => { - await OnboardingService.reset(); - await OnboardingService.start(); - if (router.canDismiss()) { - router.dismissAll(); - } - router.replace('/(onboarding)/welcome' as Href); - }; - - const handlePrev = async () => { - if (!hasPrev) return; - const prevStep = ONBOARDING_FLOW[currentIndex - 1]; - await OnboardingService.goToStep(prevStep); - if (router.canDismiss()) { - router.dismissAll(); - } - router.replace(STEP_TO_PATH[prevStep] as Href); - }; - - return ( - - Dev Tools - - - Reset - - - - - Prev - - - - - Continue - - - {extraActions && extraActions.length > 0 && ( - - {extraActions.map((action, i) => ( - - {action.label} - - ))} - - )} - - ); -} - -const styles = StyleSheet.create({ - container: { - paddingHorizontal: 16, - paddingTop: 8, - paddingBottom: 12, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: 'rgba(0,0,0,0.15)', - backgroundColor: 'rgba(0,0,0,0.03)', - }, - label: { - fontSize: 10, - fontWeight: '700', - color: '#999', - textAlign: 'center', - marginBottom: 6, - textTransform: 'uppercase', - letterSpacing: 1, - }, - buttonRow: { - flexDirection: 'row', - gap: 8, - }, - extraRow: { - flexDirection: 'row', - gap: 8, - marginTop: 6, - }, - button: { - flex: 1, - height: 40, - borderRadius: 10, - justifyContent: 'center', - alignItems: 'center', - }, - resetButton: { - backgroundColor: '#FF3B30', - }, - prevButton: { - backgroundColor: '#FFCC00', - }, - continueButton: { - backgroundColor: '#34C759', - }, - disabledButton: { - opacity: 0.35, - }, - buttonText: { - fontSize: 14, - fontWeight: '600', - color: '#FFFFFF', - }, - darkText: { - color: '#000000', - }, - disabledText: { - color: 'rgba(255,255,255,0.6)', - }, - disabledDarkText: { - color: 'rgba(0,0,0,0.4)', - }, -}); diff --git a/homeflow/components/onboarding/index.ts b/homeflow/components/onboarding/index.ts index 248289e..3cb52da 100644 --- a/homeflow/components/onboarding/index.ts +++ b/homeflow/components/onboarding/index.ts @@ -7,4 +7,3 @@ export { PermissionCard } from './PermissionCard'; export type { PermissionStatus } from './PermissionCard'; export { ConsentSection, ConsentAgreement } from './ConsentSection'; export { ContinueButton } from './ContinueButton'; -export { DevToolBar } from './DevToolBar'; diff --git a/homeflow/hooks/use-health-summary.ts b/homeflow/hooks/use-health-summary.ts index 9364a25..b4882d2 100644 --- a/homeflow/hooks/use-health-summary.ts +++ b/homeflow/hooks/use-health-summary.ts @@ -13,6 +13,7 @@ import { getDateRange, } from '@/lib/services/healthkit'; import { buildHealthSummaryDay } from '@/lib/services/health-summary'; +import { formatDateKey } from '@/lib/services/healthkit/mappers'; import type { HealthSummaryDay } from '@/lib/services/health-summary'; export function useHealthSummary(): { @@ -47,8 +48,8 @@ export function useHealthSummary(): { if (cancelled) return; - // Today's date string - const today = new Date().toISOString().split('T')[0]; + // Today's date string (local timezone — matches how HealthKit data is bucketed) + const today = formatDateKey(new Date()); // Find today's data, falling back to most recent if today has none const todayActivity = diff --git a/homeflow/lib/constants.ts b/homeflow/lib/constants.ts index aaf3ce8..a5d437e 100644 --- a/homeflow/lib/constants.ts +++ b/homeflow/lib/constants.ts @@ -34,6 +34,9 @@ export const STORAGE_KEYS = { // Notification tracking (last time we fired each reminder, to avoid spam) LAST_NOTIFICATION_HEALTHKIT: '@homeflow_last_notification_healthkit', LAST_NOTIFICATION_THRONE: '@homeflow_last_notification_throne', + + // One-time surgery complete modal (shown first time surgery date has passed) + SURGERY_MODAL_SHOWN: '@homeflow_surgery_modal_shown', } as const; // Legacy keys for backwards compatibility @@ -49,7 +52,6 @@ export enum OnboardingStep { CONSENT = 'consent', ACCOUNT = 'account', PERMISSIONS = 'permissions', - HEALTH_DATA_TEST = 'health_data_test', // Dev-only: test HealthKit + Clinical Records queries MEDICAL_HISTORY = 'medical_history', // Medical history collection (chatbot) BASELINE_SURVEY = 'baseline_survey', COMPLETE = 'complete', @@ -64,7 +66,6 @@ export const ONBOARDING_FLOW: OnboardingStep[] = [ OnboardingStep.CONSENT, OnboardingStep.ACCOUNT, OnboardingStep.PERMISSIONS, - OnboardingStep.HEALTH_DATA_TEST, OnboardingStep.MEDICAL_HISTORY, OnboardingStep.BASELINE_SURVEY, OnboardingStep.COMPLETE, @@ -80,6 +81,12 @@ export const SPEZIVIBE_TASK_ID_SYSTEM = 'http://spezivibe.com/fhir/identifier/ta */ export const CONSENT_VERSION = '1.0.0'; +/** + * Dev-only Firebase UID for testing Firestore queries without Apple Sign-In. + * Used as uid fallback when DEV_BYPASS_AUTH is active and no user is signed in. + */ +export const DEV_FIREBASE_UID = 'apple|002014.cccd386699574a369cfc75378d3770da.2143'; + /** * Study information */ diff --git a/homeflow/lib/services/healthkit/HealthKitClient.ts b/homeflow/lib/services/healthkit/HealthKitClient.ts index a14f2bf..9920067 100644 --- a/homeflow/lib/services/healthkit/HealthKitClient.ts +++ b/homeflow/lib/services/healthkit/HealthKitClient.ts @@ -169,14 +169,15 @@ export async function requestHealthPermissions(): Promise { if (!isIOS()) return []; - // Fetch all activity types in parallel + // Fetch all activity types in parallel; catch individually so Watch-only + // type failures (exerciseTime, moveTime, standTime) don't block step/energy data 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'), + queryQuantity(HK.stepCount, range, 'count').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.activeEnergy, range, 'kcal').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.exerciseTime, range, 'min').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.moveTime, range, 'min').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.standTime, range, 'min').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.distance, range, 'm').catch(() => [] as readonly QuantitySample[]), ]); // Bucket each metric by day @@ -313,13 +314,14 @@ export async function getSleep(range: DateRange): Promise { export async function getVitals(range: DateRange): Promise { if (!isIOS()) return []; - // Fetch all vitals in parallel + // Fetch all vitals in parallel; catch individually so one missing type + // doesn't block the rest 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, '%'), + queryQuantity(HK.heartRate, range, 'count/min').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.restingHeartRate, range, 'count/min').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.hrv, range, 'ms').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.respiratoryRate, range, 'count/min').catch(() => [] as readonly QuantitySample[]), + queryQuantity(HK.oxygenSaturation, range, '%').catch(() => [] as readonly QuantitySample[]), ]); // Bucket by day diff --git a/homeflow/src/services/consentPdfSync.ts b/homeflow/src/services/consentPdfSync.ts new file mode 100644 index 0000000..b459fe2 --- /dev/null +++ b/homeflow/src/services/consentPdfSync.ts @@ -0,0 +1,183 @@ +/** + * Consent PDF Generation & Upload + * + * Builds an HTML consent document from the study's consent structure, + * renders it to a PDF via expo-print, uploads the PDF bytes to Firebase + * Storage, and records metadata in Firestore. + * + * Storage path: + * users/{uid}/consent_pdfs/consent_v{version}_{timestamp}.pdf + * + * Firestore path: + * users/{uid}/consent_response/current + * + * Failures are non-fatal: the caller should still proceed with onboarding + * since consent is already recorded locally via ConsentService. + */ + +import * as Print from 'expo-print'; +import { getApp } from 'firebase/app'; +import { getStorage, ref, uploadBytes } from 'firebase/storage'; +import { doc, setDoc, serverTimestamp } from 'firebase/firestore'; + +import { CONSENT_DOCUMENT } from '@/lib/consent/consent-document'; +import { db, getAuth } from './firestore'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ConsentSignatureData { + signatureType: 'typed' | 'drawn'; + /** Full name typed by the participant, or null if they drew their signature. */ + participantName: string | null; + /** The raw value passed to ConsentService.recordConsent — typed name or marker string. */ + signatureValue: string; +} + +export interface ConsentPdfResult { + ok: boolean; + storagePath?: string; + error?: string; +} + +// ── HTML builder ────────────────────────────────────────────────────────────── + +function buildConsentHtml( + signature: ConsentSignatureData, + consentDate: string, +): string { + const sectionHtml = CONSENT_DOCUMENT.sections + .map( + s => ` +
+

${s.title}

+

${s.content + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\n/g, '
')}

+
`, + ) + .join(''); + + const signatureBlock = + signature.signatureType === 'typed' && signature.participantName + ? `${signature.participantName}` + : `[Drawn signature provided]`; + + return ` + + + + + + +

${CONSENT_DOCUMENT.title.toUpperCase()}

+
${CONSENT_DOCUMENT.studyName}
+
+ ${CONSENT_DOCUMENT.institution}  ·  + PI: ${CONSENT_DOCUMENT.principalInvestigator}  ·  + IRB: ${CONSENT_DOCUMENT.irbProtocol}  ·  + Version: ${CONSENT_DOCUMENT.version} +
+
+ ${sectionHtml} +
+
+
Participant Signature
+
${signatureBlock}
+
Date signed: ${consentDate}
+ ${signature.participantName ? `
Name: ${signature.participantName}
` : ''} +
+ +`; +} + +// ── uploadConsentPdf ────────────────────────────────────────────────────────── + +/** + * Generates and uploads a signed consent PDF for the currently signed-in user. + * + * Call this after ConsentService.recordConsent() succeeds. Failures are + * returned in the result object — callers should not throw on failure since + * the local AsyncStorage record is the source of truth for gate-keeping. + */ +export async function uploadConsentPdf( + signature: ConsentSignatureData, +): Promise { + const uid = getAuth().currentUser?.uid; + if (!uid) { + return { ok: false, error: 'no-auth: user is not signed in' }; + } + + const now = new Date(); + const consentDate = now.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const timestamp = now.toISOString().replace(/[:.]/g, '-'); + const storagePath = `users/${uid}/consent_pdfs/consent_v${CONSENT_DOCUMENT.version}_${timestamp}.pdf`; + + try { + // 1. Render HTML → local PDF file (no base64 — avoids RN ArrayBuffer/Blob issue) + const html = buildConsentHtml(signature, consentDate); + const { uri } = await Print.printToFileAsync({ html }); + + // 2. Read the local file as a React Native Blob via fetch. + // RN's fetch handles file:// URIs natively and its blob() produces a + // native Blob that is compatible with Firebase Storage's uploadBytes. + // (uploadString+base64 internally creates Blob(ArrayBuffer) which RN rejects.) + const response = await fetch(uri); + const blob = await response.blob(); + + // 3. Upload PDF to Firebase Storage + const storageRef = ref(getStorage(getApp()), storagePath); + await uploadBytes(storageRef, blob, { + contentType: 'application/pdf', + customMetadata: { + uid, + consentVersion: CONSENT_DOCUMENT.version, + signatureType: signature.signatureType, + participantName: signature.participantName ?? '', + consentDate, + }, + }); + + console.log(`[ConsentPdf] Uploaded ${storagePath}`); + + // 3. Write metadata to Firestore (users/{uid}/consent_response/current) + await setDoc( + doc(db, `users/${uid}/consent_response/current`), + { + given: true, + version: CONSENT_DOCUMENT.version, + signatureType: signature.signatureType, + participantName: signature.participantName ?? null, + studyName: CONSENT_DOCUMENT.studyName, + irbProtocol: CONSENT_DOCUMENT.irbProtocol, + storagePath, + consentTimestamp: now.toISOString(), + recordedAt: serverTimestamp(), + }, + { merge: false }, + ); + + return { ok: true, storagePath }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[ConsentPdf] upload error:', message); + return { ok: false, error: message }; + } +} diff --git a/homeflow/storage.rules b/homeflow/storage.rules new file mode 100644 index 0000000..56a956b --- /dev/null +++ b/homeflow/storage.rules @@ -0,0 +1,16 @@ +rules_version = '2'; + +service firebase.storage { + match /b/{bucket}/o { + + // Helper: only the authenticated owner may access their own files. + function isOwner(uid) { + return request.auth != null && request.auth.uid == uid; + } + + // All user data is scoped under users/{uid}/ + match /users/{uid}/{allPaths=**} { + allow read, write: if isOwner(uid); + } + } +} From 04dae55c7538943330f330656b2cc747a31b1911 Mon Sep 17 00:00:00 2001 From: chehanw Date: Tue, 10 Mar 2026 13:37:19 -0700 Subject: [PATCH 7/8] fix: resolve CI TypeScript errors - Remove HEALTH_DATA_TEST case from onboarding router (screen deleted) - Remove dead handleDevContinue from permissions.tsx (referenced deleted screen) - Commit SignaturePad.tsx with onDrawingActiveChange prop used by consent screen - Commit throneFirestore.ts with correct fetchSessions(uid, opts) signature, saveMedicalHistory, and saveThroneUserId exports used by screens - Commit healthkitSync.ts with string-typed metricType so sleepAnalysis key compiles - Commit package.json and package-lock.json with expo-print dependency --- homeflow/app/(onboarding)/index.tsx | 3 - homeflow/app/(onboarding)/permissions.tsx | 5 - homeflow/components/ui/SignaturePad.tsx | 17 ++ homeflow/package-lock.json | 11 ++ homeflow/package.json | 1 + homeflow/src/services/healthkitSync.ts | 136 ++------------ homeflow/src/services/throneFirestore.ts | 214 +++++++++++++--------- 7 files changed, 179 insertions(+), 208 deletions(-) diff --git a/homeflow/app/(onboarding)/index.tsx b/homeflow/app/(onboarding)/index.tsx index a57d922..b4af44f 100644 --- a/homeflow/app/(onboarding)/index.tsx +++ b/homeflow/app/(onboarding)/index.tsx @@ -71,9 +71,6 @@ export default function OnboardingRouter() { case OnboardingStep.PERMISSIONS: return ; - case OnboardingStep.HEALTH_DATA_TEST: - return ; - case OnboardingStep.MEDICAL_HISTORY: return ; diff --git a/homeflow/app/(onboarding)/permissions.tsx b/homeflow/app/(onboarding)/permissions.tsx index 6581fe4..9bb9215 100644 --- a/homeflow/app/(onboarding)/permissions.tsx +++ b/homeflow/app/(onboarding)/permissions.tsx @@ -197,11 +197,6 @@ export default function PermissionsScreen() { } }; - const handleDevContinue = async () => { - await OnboardingService.goToStep(OnboardingStep.HEALTH_DATA_TEST); - router.push('/(onboarding)/health-data-test' as Href); - }; - return ( diff --git a/homeflow/components/ui/SignaturePad.tsx b/homeflow/components/ui/SignaturePad.tsx index 3ca65b3..853124a 100644 --- a/homeflow/components/ui/SignaturePad.tsx +++ b/homeflow/components/ui/SignaturePad.tsx @@ -24,6 +24,8 @@ export interface SignaturePadRef { interface SignaturePadProps { onChanged: (hasSignature: boolean) => void; + /** Called with `true` when a stroke begins, `false` when it ends. Use to disable parent ScrollView scrolling while drawing. */ + onDrawingActiveChange?: (active: boolean) => void; strokeColor?: string; backgroundColor?: string; height?: number; @@ -33,6 +35,7 @@ export const SignaturePad = forwardRef( function SignaturePad( { onChanged, + onDrawingActiveChange, strokeColor = '#1A1A1A', backgroundColor = '#F9F9F9', height = 160, @@ -41,6 +44,10 @@ export const SignaturePad = forwardRef( ) { const [strokes, setStrokes] = useState([]); + // Keep a ref so PanResponder callbacks (created once) always call the latest prop + const onDrawingActiveChangeRef = useRef(onDrawingActiveChange); + onDrawingActiveChangeRef.current = onDrawingActiveChange; + useImperativeHandle(ref, () => ({ clear: () => { setStrokes([]); @@ -51,9 +58,13 @@ export const SignaturePad = forwardRef( const panResponder = useRef( PanResponder.create({ + // Capture touches before the parent ScrollView can intercept them onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, onPanResponderGrant: evt => { + onDrawingActiveChangeRef.current?.(true); const { locationX, locationY } = evt.nativeEvent; setStrokes(prev => [...prev, [{ x: locationX, y: locationY }]]); onChanged(true); @@ -68,6 +79,12 @@ export const SignaturePad = forwardRef( return next; }); }, + onPanResponderRelease: () => { + onDrawingActiveChangeRef.current?.(false); + }, + onPanResponderTerminate: () => { + onDrawingActiveChangeRef.current?.(false); + }, }), ).current; diff --git a/homeflow/package-lock.json b/homeflow/package-lock.json index d63a251..70c46cb 100644 --- a/homeflow/package-lock.json +++ b/homeflow/package-lock.json @@ -36,6 +36,7 @@ "expo-image": "~3.0.11", "expo-linking": "~8.0.11", "expo-notifications": "~0.32.16", + "expo-print": "~15.0.8", "expo-router": "~6.0.21", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", @@ -9435,6 +9436,16 @@ "react-native": "*" } }, + "node_modules/expo-print": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-15.0.8.tgz", + "integrity": "sha512-4O0Qzm0On5AmJIl9d+BT+ieTipFp658nHI4aX7vKEFPfj3dfQxG6rDJJpca+rrc9c4Ha8ZFYGvxJG5+4lFq2Pw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "6.0.22", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.22.tgz", diff --git a/homeflow/package.json b/homeflow/package.json index bedb7fa..55b72d3 100644 --- a/homeflow/package.json +++ b/homeflow/package.json @@ -39,6 +39,7 @@ "expo-image": "~3.0.11", "expo-linking": "~8.0.11", "expo-notifications": "~0.32.16", + "expo-print": "~15.0.8", "expo-router": "~6.0.21", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", diff --git a/homeflow/src/services/healthkitSync.ts b/homeflow/src/services/healthkitSync.ts index 64aa8a0..347240c 100644 --- a/homeflow/src/services/healthkitSync.ts +++ b/homeflow/src/services/healthkitSync.ts @@ -3,17 +3,17 @@ * * Data model * ────────── - * users/{uid}/healthkit/{metricType}/samples/{sampleId} + * users/{uid}/hk_{metricType}/{sampleId} * value, unit, startDate, endDate, sourceName?, deviceName?, * metadata?, createdAt, updatedAt * - * users/{uid}/healthkitSync/{metricType} + * users/{uid}/hk_sync/{metricType} * lastSyncedAt, lastRunAt, lastStatus, lastError? * * Idempotency * ─────────── * Each sample's Firestore doc ID is the HealthKit UUID, which is stable - * across retries. Re-syncing the same sample overwrites the same doc + * across retries. Re-syncing the same sample overwrites the same doc * (no duplicates). * * Incremental sync @@ -74,7 +74,6 @@ interface SyncState { lastError?: string; } -/** Shape written to Firestore for each sample. */ interface FirestoreSampleData { value: number; unit: string; @@ -87,7 +86,6 @@ interface FirestoreSampleData { updatedAt: FieldValue; } -/** Result returned by syncMetric(). */ export interface SyncMetricResult { ok: boolean; written: number; @@ -95,7 +93,6 @@ export interface SyncMetricResult { error?: string; } -/** Result returned by syncAllHealthKit(). */ export interface SyncAllResult { ok: boolean; results: Record; @@ -103,36 +100,18 @@ export interface SyncAllResult { // ── Constants ───────────────────────────────────────────────────────────────── -/** Overlap window applied to the start of every incremental query. - * Catches samples that were recorded slightly before the last sync boundary - * due to device clock drift or delayed HK delivery. */ -const OVERLAP_WINDOW_MS = 5 * 60 * 1_000; // 5 minutes - -/** How far back to look on the very first sync for a metric. */ +const OVERLAP_WINDOW_MS = 5 * 60 * 1_000; const DEFAULT_LOOKBACK_DAYS = 30; - -/** Maximum docs per Firestore batch (hard limit is 500; leave headroom). */ const BATCH_SIZE = 400; // ── buildSampleId ───────────────────────────────────────────────────────────── -/** - * Returns a stable Firestore document ID for a HealthKit sample. - * - * Prefers the HK UUID (always present in kingstinct v13). Falls back - * to a SHA-1 digest of key fields to cover any edge case where UUID - * is empty (shouldn't happen in practice). - */ async function buildSampleId( metricType: MetricType, sample: QuantitySample, ): Promise { - if (sample.uuid) { - // HealthKit UUIDs are lowercase hex + hyphens — safe as Firestore doc IDs. - return sample.uuid; - } + if (sample.uuid) return sample.uuid; - // Deterministic fallback: SHA-1 of (metricType|startISO|endISO|value|unit|source) const toDate = (d: unknown): Date => d instanceof Date ? d : new Date(String(d)); @@ -151,10 +130,6 @@ async function buildSampleId( // ── toFirestoreSample ───────────────────────────────────────────────────────── -/** - * Converts a raw HealthKit QuantitySample into the shape stored in Firestore. - * Omits createdAt/updatedAt — those are added at the write site. - */ function toFirestoreSample( metricType: MetricType, sample: QuantitySample, @@ -184,12 +159,6 @@ function toFirestoreSample( // ── fetchHealthKitSamples ───────────────────────────────────────────────────── -/** - * Queries HealthKit for samples of the given metric type starting from - * sinceDate (minus the overlap window). - * - * Returns an empty array on non-iOS platforms. - */ async function fetchHealthKitSamples( metricType: MetricType, sinceDate: Date, @@ -197,26 +166,18 @@ async function fetchHealthKitSamples( if (Platform.OS !== "ios") return []; const config = METRIC_CONFIG[metricType]; - - // Subtract the overlap window so we don't miss samples at the boundary. const startDate = new Date(sinceDate.getTime() - OVERLAP_WINDOW_MS); const endDate = new Date(); return queryQuantitySamples(config.identifier as any, { - limit: 0, // 0 = no limit — fetch every sample in the window + limit: 0, unit: config.unit, - filter: { - date: { startDate, endDate }, - }, + filter: { date: { startDate, endDate } }, }); } // ── writeSamplesBatch ───────────────────────────────────────────────────────── -/** - * Writes sample documents to Firestore in batches of BATCH_SIZE. - * Uses set() (overwrite) so repeated runs are idempotent given a stable doc ID. - */ async function writeSamplesBatch( uid: string, metricType: MetricType, @@ -225,13 +186,11 @@ async function writeSamplesBatch( for (let i = 0; i < entries.length; i += BATCH_SIZE) { const chunk = entries.slice(i, i + BATCH_SIZE); const batch = writeBatch(db); - const basePath = `users/${uid}/healthkit/${metricType}/samples`; - console.log(`[HealthKit] Writing ${chunk.length} docs → ${basePath}/`); + const collectionPath = `users/${uid}/hk_${metricType}`; + console.log(`[HealthKit] Writing ${chunk.length} docs → ${collectionPath}/`); for (const { id, data } of chunk) { - const ref = doc(db, `${basePath}/${id}`); - // Full overwrite — idempotency is guaranteed by the stable doc ID. - batch.set(ref, data); + batch.set(doc(db, `${collectionPath}/${id}`), data); } await batch.commit(); @@ -241,15 +200,11 @@ async function writeSamplesBatch( // ── Sync state helpers ──────────────────────────────────────────────────────── -/** - * Reads the last successful sync timestamp for a metric. - * Returns null if this metric has never been synced. - */ export async function getLastSync( uid: string, - metricType: MetricType, + metricType: string, ): Promise { - const ref = doc(db, `users/${uid}/healthkitSync/${metricType}`); + const ref = doc(db, `users/${uid}/hk_sync/${metricType}`); const snap = await getDoc(ref); if (!snap.exists()) return null; @@ -257,20 +212,16 @@ export async function getLastSync( return state.lastSyncedAt?.toDate() ?? null; } -/** - * Merges a partial sync-state update into Firestore. - * Always stamps lastRunAt with the server timestamp. - */ export async function setSyncState( uid: string, - metricType: MetricType, + metricType: string, patch: { lastSyncedAt?: Timestamp; lastStatus: "ok" | "error"; lastError?: string; }, ): Promise { - const path = `users/${uid}/healthkitSync/${metricType}`; + const path = `users/${uid}/hk_sync/${metricType}`; console.log(`[HealthKit] setSyncState → ${path} status=${patch.lastStatus}`); const ref = doc(db, path); await setDoc( @@ -287,41 +238,26 @@ export async function setSyncState( // ── syncMetric ──────────────────────────────────────────────────────────────── -/** - * Syncs a single HealthKit metric for the signed-in user. - * - * @param metricType One of the keys in METRIC_CONFIG. - * @param options.dryRun If true, fetches and transforms samples but skips - * all Firestore writes (useful for testing). - */ export async function syncMetric( metricType: MetricType, options?: { dryRun?: boolean }, ): Promise { const uid = getAuth().currentUser?.uid; if (!uid) { - return { - ok: false, - written: 0, - skipped: 0, - error: "no-auth: user is not signed in", - }; + return { ok: false, written: 0, skipped: 0, error: "no-auth: user is not signed in" }; } try { - // 1. Determine the time window for this incremental run. const lastSync = await getLastSync(uid, metricType); const sinceDate = lastSync ?? new Date(Date.now() - DEFAULT_LOOKBACK_DAYS * 24 * 60 * 60 * 1_000); - // 2. Pull samples from HealthKit. const hkSamples = await fetchHealthKitSamples(metricType, sinceDate); if (hkSamples.length === 0) { return { ok: true, written: 0, skipped: 0 }; } - // 3. Transform each sample into a { id, data } pair. const entries: { id: string; data: FirestoreSampleData }[] = await Promise.all( hkSamples.map(async (sample) => { @@ -336,10 +272,8 @@ export async function syncMetric( ); if (!options?.dryRun) { - // 4. Write to Firestore in batches. await writeSamplesBatch(uid, metricType, entries); - // 5. Advance lastSyncedAt to the maximum endDate in this batch. const toDate = (d: unknown): Date => d instanceof Date ? d : new Date(String(d)); @@ -357,29 +291,16 @@ export async function syncMetric( return { ok: true, written: entries.length, skipped: 0 }; } catch (err) { const message = err instanceof Error ? err.message : String(err); - - // Best-effort: record the error in sync state so it's visible in Firestore. await setSyncState(uid, metricType, { lastStatus: "error", lastError: message, - }).catch(() => { - // Don't let the state-write failure shadow the original error. - }); - + }).catch(() => {}); return { ok: false, written: 0, skipped: 0, error: message }; } } // ── syncAllHealthKit ────────────────────────────────────────────────────────── -/** - * Syncs all configured HealthKit metrics for the signed-in user. - * - * Metrics are processed sequentially to avoid hammering HealthKit with - * simultaneous queries, which can cause spurious empty results. - * - * Returns { ok: true } only if every metric sync succeeded. - */ export async function syncAllHealthKit(): Promise { const metricTypes = Object.keys(METRIC_CONFIG) as MetricType[]; const results = {} as Record; @@ -398,7 +319,6 @@ export async function syncAllHealthKit(): Promise { const SLEEP_ANALYSIS_IDENTIFIER = "HKCategoryTypeIdentifierSleepAnalysis" as const; const SLEEP_SYNC_KEY = "sleepAnalysis"; -/** Shape written to Firestore for each sleep sample. */ interface FirestoreSleepSampleData { stage: string; stageValue: number; @@ -412,17 +332,12 @@ interface FirestoreSleepSampleData { updatedAt: FieldValue; } -/** Result returned by syncSleep(). */ export interface SyncSleepResult { ok: boolean; written: number; error?: string; } -/** - * Builds a stable Firestore doc ID for a sleep category sample. - * Prefers the HK UUID, falls back to SHA-1 of key fields. - */ async function buildSleepSampleId(sample: { uuid?: string; startDate: Date | string; @@ -454,11 +369,8 @@ async function buildSleepSampleId(sample: { /** * Syncs sleep analysis samples from HealthKit to Firestore. * - * Firestore path: users/{uid}/healthkit/sleepAnalysis/samples/{sampleId} - * Sync state: users/{uid}/healthkitSync/sleepAnalysis - * - * Each sample doc stores the stage, night date, duration, and timestamps. - * Re-syncing the same sample is idempotent (stable doc ID from HK UUID). + * Firestore path: users/{uid}/hk_sleepAnalysis/{sampleId} + * Sync state: users/{uid}/hk_sync/sleepAnalysis */ export async function syncSleep( options?: { dryRun?: boolean }, @@ -471,7 +383,6 @@ export async function syncSleep( } try { - // 1. Determine incremental window. const lastSync = await getLastSync(uid, SLEEP_SYNC_KEY); const sinceDate = lastSync ?? @@ -480,7 +391,6 @@ export async function syncSleep( const startDate = new Date(sinceDate.getTime() - OVERLAP_WINDOW_MS); const endDate = new Date(); - // 2. Pull sleep category samples from HealthKit. const rawSamples = await queryCategorySamples(SLEEP_ANALYSIS_IDENTIFIER as any, { limit: 0, filter: { date: { startDate, endDate } }, @@ -490,7 +400,6 @@ export async function syncSleep( return { ok: true, written: 0 }; } - // 3. Transform each sample. const toDate = (d: unknown): Date => d instanceof Date ? d : new Date(String(d)); @@ -522,8 +431,7 @@ export async function syncSleep( ); if (!options?.dryRun) { - // 4. Write in batches. - const basePath = `users/${uid}/healthkit/${SLEEP_SYNC_KEY}/samples`; + const basePath = `users/${uid}/hk_${SLEEP_SYNC_KEY}`; console.log(`[HealthKit] Writing ${entries.length} sleep samples → ${basePath}/`); for (let i = 0; i < entries.length; i += BATCH_SIZE) { @@ -536,7 +444,6 @@ export async function syncSleep( console.log(`[HealthKit] Sleep batch committed (${chunk.length} docs)`); } - // 5. Advance sync cursor. const maxEndDate = rawSamples.reduce((max, s) => { const end = toDate((s as any).endDate); return end > max ? end : max; @@ -598,10 +505,7 @@ export async function bootstrapHealthKitSync(): Promise { } if (fhirResult.ok) { - console.log( - "[HealthKit] bootstrapHealthKitSync: FHIR prefill synced OK", - fhirResult.sourceRecordCounts, - ); + console.log("[HealthKit] bootstrapHealthKitSync: FHIR prefill synced OK", fhirResult.sourceRecordCounts); } else { console.warn("[HealthKit] bootstrapHealthKitSync: FHIR prefill sync error:", fhirResult.error); } diff --git a/homeflow/src/services/throneFirestore.ts b/homeflow/src/services/throneFirestore.ts index 2e1cf38..9cf441e 100644 --- a/homeflow/src/services/throneFirestore.ts +++ b/homeflow/src/services/throneFirestore.ts @@ -1,8 +1,12 @@ /** - * Firestore read service for Throne uroflow data. + * Firestore read/write service for Throne uroflow data. * - * Reads sessions and metrics from Firestore collections - * written by the Cloud Function ingestion pipeline. + * All Throne paths are scoped under users/{uid}: + * throne_sessions/{sessionId} + * throne_metrics/{metricId} + * + * Surgery date is stored at: + * users/{uid}/surgery_date/current → { surgeryDate: "YYYY-MM-DD" } */ import { @@ -14,10 +18,10 @@ import { doc, getDoc, setDoc, + serverTimestamp, } from "firebase/firestore"; import {db} from "./firebase"; -// Re-export the same types the mock module used, for compatibility export interface ThroneSession { id: string; studyId: string; @@ -47,30 +51,22 @@ export interface ThroneMetric { } /** - * Fetch sessions from Firestore. - * Sorting and date range filtering done client-side. - * By default only returns sessions that have at least one metric (metricCount > 0). + * Fetch sessions for a user from Firestore. + * Only returns sessions with at least one metric (metricCount > 0). + * Date range filtering is applied client-side after the query. */ -export async function fetchSessions(opts?: { - userId?: string; +export async function fetchSessions(uid: string, opts?: { startDate?: Date; endDate?: Date; }): Promise { - const constraints: QueryConstraint[] = []; - - if (opts?.userId) { - constraints.push(where("userId", "==", opts.userId)); - } - - // Only return sessions that have actual recorded metric data - constraints.push(where("metricCount", ">", 0)); + const constraints: QueryConstraint[] = [ + where("metricCount", ">", 0), + ]; - const q = query(collection(db, "sessions"), ...constraints); + const q = query(collection(db, `users/${uid}/throne_sessions`), ...constraints); const snap = await getDocs(q); + let sessions = snap.docs.map((d) => d.data() as ThroneSession); - let sessions = snap.docs.map((doc) => doc.data() as ThroneSession); - - // Client-side date filtering if (opts?.startDate || opts?.endDate) { const startMs = opts.startDate?.getTime() ?? 0; const endMs = opts.endDate?.getTime() ?? Infinity; @@ -80,18 +76,16 @@ export async function fetchSessions(opts?: { }); } - // Sort descending by startTs sessions.sort((a, b) => new Date(b.startTs).getTime() - new Date(a.startTs).getTime()); - return sessions; } /** - * Batch-fetch metrics for multiple sessions in a single (or few) Firestore - * queries. Firestore "in" supports up to 30 values, so large arrays are - * automatically split into parallel batches. + * Batch-fetch metrics for multiple sessions. + * Firestore "in" supports up to 30 values — large arrays are split into + * parallel batches automatically. */ -export async function fetchMetricsBatch(sessionIds: string[]): Promise { +export async function fetchMetricsBatch(uid: string, sessionIds: string[]): Promise { if (sessionIds.length === 0) return []; const BATCH_SIZE = 30; @@ -101,59 +95,44 @@ export async function fetchMetricsBatch(sessionIds: string[]): Promise - getDocs(query(collection(db, 'metrics'), where('sessionId', 'in', batch))), + batches.map((batch) => + getDocs(query( + collection(db, `users/${uid}/throne_metrics`), + where("sessionId", "in", batch), + )), ), ); - return snapshots.flatMap(snap => snap.docs.map(d => d.data() as ThroneMetric)); + return snapshots.flatMap((snap) => snap.docs.map((d) => d.data() as ThroneMetric)); } -// ─── Surgery Date ───────────────────────────────────────────────────────────── - /** - * Read surgery date from Firestore. - * Tries: users/{uid}/profile.surgeryDate → users/{uid}/settings.surgeryDate - * Returns an ISO date string (YYYY-MM-DD) or null if not set. + * Fetch all metrics for a single session, sorted ascending by timestamp. */ -export async function fetchSurgeryDate(uid: string): Promise { - const paths = [ - `users/${uid}/profile`, - `users/${uid}`, - `users/${uid}/settings`, - ]; - - for (const path of paths) { - try { - const snap = await getDoc(doc(db, path)); - if (snap.exists()) { - const data = snap.data(); - const sd = data?.surgeryDate; - if (sd) { - // Handle Firestore Timestamp objects and ISO strings - if (typeof sd === 'string') return sd.slice(0, 10); - if (sd?.toDate) return (sd.toDate() as Date).toISOString().slice(0, 10); - } - } - } catch { - // Path may not exist — try next - } - } - - return null; +export async function fetchMetricsForSession(uid: string, sessionId: string): Promise { + const q = query( + collection(db, `users/${uid}/throne_metrics`), + where("sessionId", "==", sessionId), + ); + const snap = await getDocs(q); + const metrics = snap.docs.map((d) => d.data() as ThroneMetric); + metrics.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); + return metrics; } +// ─── Surgery Date ───────────────────────────────────────────────────────────── + /** - * Read the Throne user ID for a given Firebase UID. - * Looks for `throneUserId` field in users/{uid}. - * Returns null if the field is not set. + * Read surgery date from users/{uid}/surgery_date/current. + * Returns an ISO date string (YYYY-MM-DD) or null if not set. */ -export async function fetchThroneUserId(uid: string): Promise { +export async function fetchSurgeryDate(uid: string): Promise { try { - const snap = await getDoc(doc(db, 'users', uid)); + const snap = await getDoc(doc(db, `users/${uid}/surgery_date/current`)); if (snap.exists()) { - const val = snap.data()?.throneUserId; - if (typeof val === 'string' && val) return val; + const sd = snap.data()?.surgeryDate; + if (typeof sd === "string" && sd) return sd.slice(0, 10); + if (sd?.toDate) return (sd.toDate() as Date).toISOString().slice(0, 10); } } catch { // Document may not exist — return null @@ -162,33 +141,100 @@ export async function fetchThroneUserId(uid: string): Promise { } /** - * Persist surgery date to Firestore at users/{uid}/settings. - * Uses merge so existing fields are not overwritten. + * Persist the Throne User ID to the root users/{uid} document. + * + * The syncThroneUserMap Cloud Function trigger watches users/{uid} and + * automatically creates the throneUserMap/{throneUserId} → { firebaseUid } + * reverse-lookup entry, so the ingestion function can route sessions to + * the correct user without any manual CRC steps. */ -export async function saveSurgeryDate(uid: string, dateStr: string): Promise { +export async function saveThroneUserId(uid: string, throneUserId: string): Promise { await setDoc( - doc(db, 'users', uid, 'settings'), - { surgeryDate: dateStr }, + doc(db, `users/${uid}`), + { throneUserId, throneUserIdSetAt: new Date().toISOString() }, { merge: true }, ); } /** - * Fetch all metrics for a given session, sorted by timestamp ascending. + * Persist surgery date to users/{uid}/surgery_date/current. */ -export async function fetchMetricsForSession( - sessionId: string, -): Promise { - const q = query( - collection(db, "metrics"), - where("sessionId", "==", sessionId), +export async function saveSurgeryDate(uid: string, dateStr: string): Promise { + await setDoc( + doc(db, `users/${uid}/surgery_date/current`), + { surgeryDate: dateStr, updatedAt: new Date().toISOString() }, + { merge: true }, ); +} - const snap = await getDocs(q); - const metrics = snap.docs.map((doc) => doc.data() as ThroneMetric); +// ─── Medical History ────────────────────────────────────────────────────────── - // Sort ascending by timestamp client-side - metrics.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); +export interface MedHistoryMedication { + name: string; + brandName?: string; + groupKey: string; // alphaBlockers | fiveARIs | anticholinergics | beta3Agonists | otherBPH +} - return metrics; +export interface MedHistoryProcedure { + name: string; + commonName?: string; + date?: string; + isBPH: boolean; +} + +export interface MedHistoryCondition { + name: string; +} + +export interface MedHistoryLabValue { + value: number; + unit: string; + date: string; + referenceRange?: string; +} + +export interface MedHistoryDocument { + // User-entered demographics + demographics: { + name: string; + ethnicity: string; + race: string; + // From HealthKit prefill (not user-entered) + age: number | null; + biologicalSex: string | null; + dateOfBirth: string | null; + }; + // User-confirmed (possibly edited) from prefill + medications: MedHistoryMedication[]; + surgicalHistory: MedHistoryProcedure[]; + conditions: MedHistoryCondition[]; + // From FHIR prefill only — not collected in user form + labs: { + psa: MedHistoryLabValue | null; + hba1c: MedHistoryLabValue | null; + urinalysis: MedHistoryLabValue | null; + }; + clinicalMeasurements: { + pvr: MedHistoryLabValue | null; + uroflowQmax: MedHistoryLabValue | null; + volumeVoided: MedHistoryLabValue | null; + mobility: string | null; + }; + savedAt: unknown; // serverTimestamp() +} + +/** + * Write combined medical history (user form + FHIR prefill remainder) + * to users/{uid}/medical_history/current. + * Overwrites on every call — always reflects latest confirmed data. + */ +export async function saveMedicalHistory( + uid: string, + data: Omit, +): Promise { + await setDoc( + doc(db, `users/${uid}/medical_history/current`), + { ...data, savedAt: serverTimestamp() }, + { merge: false }, + ); } From bd696eb4c05caf79d90ed5a467f57e477f51b7e3 Mon Sep 17 00:00:00 2001 From: chehanw Date: Tue, 10 Mar 2026 13:39:16 -0700 Subject: [PATCH 8/8] fix: commit deletion of use-throne-user-id.ts to match throneFirestore API --- homeflow/hooks/use-throne-user-id.ts | 47 ---------------------------- 1 file changed, 47 deletions(-) delete mode 100644 homeflow/hooks/use-throne-user-id.ts diff --git a/homeflow/hooks/use-throne-user-id.ts b/homeflow/hooks/use-throne-user-id.ts deleted file mode 100644 index 67ead66..0000000 --- a/homeflow/hooks/use-throne-user-id.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * useThroneUserId - * - * Reads the Throne-internal user ID for the signed-in Firebase user. - * The Throne userId is stored in users/{uid}.throneUserId by the study - * coordinator during participant enrollment. - * - * This ID is used to filter Firestore session queries so each user only - * sees their own uroflow data. - */ - -import { useState, useEffect } from 'react'; -import { fetchThroneUserId } from '@/src/services/throneFirestore'; - -interface ThroneUserIdState { - /** The Throne-internal user ID, or null if not yet enrolled / not found */ - throneUserId: string | null; - /** True while the Firestore read is in flight */ - isLoading: boolean; -} - -export function useThroneUserId(uid: string | null): ThroneUserIdState { - const [state, setState] = useState({ - throneUserId: null, - isLoading: uid !== null, - }); - - useEffect(() => { - if (!uid) { - setState({ throneUserId: null, isLoading: false }); - return; - } - - let cancelled = false; - setState({ throneUserId: null, isLoading: true }); - - fetchThroneUserId(uid).then((id) => { - if (!cancelled) setState({ throneUserId: id, isLoading: false }); - }).catch(() => { - if (!cancelled) setState({ throneUserId: null, isLoading: false }); - }); - - return () => { cancelled = true; }; - }, [uid]); - - return state; -}