Skip to content

Commit 70c7a36

Browse files
committed
feat: Add sleep model debug panel for Fitbit/wearable data
- runDebugReport() fetches sleep stages, HR, HRV from Health Connect - Shows how many samples match each sleep stage - Displays learned model profiles (HR/HRV means and stds per stage) - Buttons to retrain model and clear stored data
1 parent 8740850 commit 70c7a36

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

app/(tabs)/settings.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,54 @@ import { VolumeSetup } from '@/components/VolumeSetup';
1717
import { MicrophoneTest } from '@/components/MicrophoneTest';
1818
import { useHealth } from '@/hooks/useHealth';
1919
import { colors, spacing } from '@/theme/tokens';
20+
import {
21+
runDebugReport,
22+
formatDebugReport,
23+
learnFromRecentNights,
24+
clearModel,
25+
type DebugReport,
26+
} from '@/services/sleepStageLearning';
2027

2128
export default function SettingsScreen() {
2229
const [showSleepDebug, setShowSleepDebug] = useState(false);
2330
const [showVolumeSetup, setShowVolumeSetup] = useState(false);
2431
const [showMicTest, setShowMicTest] = useState(false);
2532
const [showWearable, setShowWearable] = useState(false);
33+
const [showModelDebug, setShowModelDebug] = useState(false);
34+
const [modelDebugReport, setModelDebugReport] = useState<string | null>(null);
35+
const [isLoadingModelDebug, setIsLoadingModelDebug] = useState(false);
2636

2737
const health = useHealth();
2838

39+
const handleRunModelDebug = async () => {
40+
setIsLoadingModelDebug(true);
41+
setModelDebugReport(null);
42+
try {
43+
const report = await runDebugReport(48);
44+
setModelDebugReport(formatDebugReport(report));
45+
} catch (error) {
46+
setModelDebugReport(`Error: ${error}`);
47+
} finally {
48+
setIsLoadingModelDebug(false);
49+
}
50+
};
51+
52+
const handleRetrainModel = async () => {
53+
setIsLoadingModelDebug(true);
54+
try {
55+
await learnFromRecentNights(48);
56+
await handleRunModelDebug();
57+
} catch (error) {
58+
setModelDebugReport(`Retrain error: ${error}`);
59+
setIsLoadingModelDebug(false);
60+
}
61+
};
62+
63+
const handleClearModel = async () => {
64+
await clearModel();
65+
setModelDebugReport('Model cleared. Tap "Run Debug Report" to see current state.');
66+
};
67+
2968
useEffect(() => {
3069
if (showWearable && health.status?.permissionsGranted) {
3170
health.startVitalsPolling();
@@ -235,6 +274,64 @@ export default function SettingsScreen() {
235274
onPress={() => setShowSleepDebug(!showSleepDebug)}
236275
/>
237276
{showSleepDebug && <SleepDebugPanel />}
277+
278+
<MenuRow
279+
icon="analytics-outline"
280+
label="Sleep Model Debug (Fitbit/Wearable)"
281+
onPress={() => setShowModelDebug(!showModelDebug)}
282+
/>
283+
{showModelDebug && (
284+
<View style={styles.expandedSection}>
285+
<View style={styles.debugButtonRow}>
286+
<Pressable
287+
style={styles.debugButton}
288+
onPress={handleRunModelDebug}
289+
disabled={isLoadingModelDebug}
290+
>
291+
{isLoadingModelDebug ? (
292+
<ActivityIndicator size="small" color={colors.primary[500]} />
293+
) : (
294+
<Ionicons name="play" size={16} color={colors.primary[500]} />
295+
)}
296+
<Text variant="caption" color="primary">
297+
Run Debug Report
298+
</Text>
299+
</Pressable>
300+
<Pressable
301+
style={styles.debugButton}
302+
onPress={handleRetrainModel}
303+
disabled={isLoadingModelDebug}
304+
>
305+
<Ionicons name="refresh" size={16} color={colors.accent.cyan} />
306+
<Text variant="caption" color="primary">
307+
Retrain Model
308+
</Text>
309+
</Pressable>
310+
<Pressable style={styles.debugButton} onPress={handleClearModel}>
311+
<Ionicons name="trash" size={16} color={colors.error} />
312+
<Text variant="caption" color="primary">
313+
Clear
314+
</Text>
315+
</Pressable>
316+
</View>
317+
{modelDebugReport && (
318+
<ScrollView
319+
style={styles.debugOutput}
320+
horizontal={false}
321+
nestedScrollEnabled={true}
322+
>
323+
<Text
324+
variant="caption"
325+
color="muted"
326+
style={styles.debugOutputText}
327+
selectable={true}
328+
>
329+
{modelDebugReport}
330+
</Text>
331+
</ScrollView>
332+
)}
333+
</View>
334+
)}
238335
</View>
239336

240337
<View style={styles.versionSection}>
@@ -459,4 +556,30 @@ const styles = StyleSheet.create({
459556
closeButton: {
460557
padding: spacing.sm,
461558
},
559+
debugButtonRow: {
560+
flexDirection: 'row',
561+
gap: spacing.sm,
562+
marginBottom: spacing.md,
563+
flexWrap: 'wrap',
564+
},
565+
debugButton: {
566+
flexDirection: 'row',
567+
alignItems: 'center',
568+
gap: spacing.xs,
569+
backgroundColor: '#252542',
570+
paddingHorizontal: spacing.sm,
571+
paddingVertical: spacing.xs,
572+
borderRadius: 6,
573+
},
574+
debugOutput: {
575+
maxHeight: 400,
576+
backgroundColor: '#1a1a2e',
577+
borderRadius: 8,
578+
padding: spacing.sm,
579+
},
580+
debugOutputText: {
581+
fontFamily: 'CourierPrime_400Regular',
582+
fontSize: 11,
583+
lineHeight: 16,
584+
},
462585
});

services/sleepStageLearning.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,213 @@ function countNights(timestamps: Date[]): number {
319319
}
320320
return uniqueDays.size;
321321
}
322+
323+
export interface DebugReport {
324+
timestamp: string;
325+
platform: string;
326+
sleepStages: {
327+
total: number;
328+
byStage: Record<string, number>;
329+
samples: Array<{ stage: string; start: string; end: string; durationMin: number }>;
330+
};
331+
heartRate: {
332+
total: number;
333+
oldest: string | null;
334+
newest: string | null;
335+
samples: Array<{ bpm: number; time: string }>;
336+
};
337+
hrv: {
338+
total: number;
339+
oldest: string | null;
340+
newest: string | null;
341+
samples: Array<{ ms: number; time: string }>;
342+
};
343+
matching: {
344+
stagesWithHR: Record<string, number>;
345+
stagesWithHRV: Record<string, number>;
346+
};
347+
model: LearnedModel | null;
348+
errors: string[];
349+
}
350+
351+
export async function runDebugReport(hoursBack: number = 48): Promise<DebugReport> {
352+
const platform = Platform.OS;
353+
const errors: string[] = [];
354+
const report: DebugReport = {
355+
timestamp: new Date().toISOString(),
356+
platform,
357+
sleepStages: { total: 0, byStage: {}, samples: [] },
358+
heartRate: { total: 0, oldest: null, newest: null, samples: [] },
359+
hrv: { total: 0, oldest: null, newest: null, samples: [] },
360+
matching: { stagesWithHR: {}, stagesWithHRV: {} },
361+
model: null,
362+
errors: [],
363+
};
364+
365+
if (platform !== 'android' && platform !== 'ios') {
366+
errors.push(`Unsupported platform: ${platform}`);
367+
report.errors = errors;
368+
return report;
369+
}
370+
371+
try {
372+
const sleepStages =
373+
platform === 'ios'
374+
? await healthKit.getRecentSleepSessions(hoursBack)
375+
: await healthConnect.getRecentSleepSessions(hoursBack);
376+
377+
report.sleepStages.total = sleepStages.length;
378+
379+
const byStage: Record<string, number> = {};
380+
for (const s of sleepStages) {
381+
byStage[s.stage] = (byStage[s.stage] || 0) + 1;
382+
}
383+
report.sleepStages.byStage = byStage;
384+
385+
report.sleepStages.samples = sleepStages.slice(0, 20).map((s) => ({
386+
stage: s.stage,
387+
start: s.startTime,
388+
end: s.endTime,
389+
durationMin: Math.round(
390+
(new Date(s.endTime).getTime() - new Date(s.startTime).getTime()) / 60000
391+
),
392+
}));
393+
394+
const hrSamples =
395+
platform === 'ios'
396+
? await healthKit.getRecentHeartRate(hoursBack * 60)
397+
: await healthConnect.getRecentHeartRate(hoursBack * 60);
398+
399+
report.heartRate.total = hrSamples.length;
400+
if (hrSamples.length > 0) {
401+
const sorted = [...hrSamples].sort(
402+
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
403+
);
404+
report.heartRate.oldest = sorted[0].time;
405+
report.heartRate.newest = sorted[sorted.length - 1].time;
406+
report.heartRate.samples = hrSamples.slice(0, 10).map((s) => ({
407+
bpm: s.beatsPerMinute,
408+
time: s.time,
409+
}));
410+
}
411+
412+
const hrvSamples =
413+
platform === 'ios'
414+
? await healthKit.getRecentHRV(hoursBack * 60)
415+
: await healthConnect.getRecentHRV(hoursBack * 60);
416+
417+
report.hrv.total = hrvSamples.length;
418+
if (hrvSamples.length > 0) {
419+
const sorted = [...hrvSamples].sort(
420+
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()
421+
);
422+
report.hrv.oldest = sorted[0].time;
423+
report.hrv.newest = sorted[sorted.length - 1].time;
424+
report.hrv.samples = hrvSamples.slice(0, 10).map((s) => ({
425+
ms: s.heartRateVariabilityMillis,
426+
time: s.time,
427+
}));
428+
}
429+
430+
const stagesWithHR: Record<string, number> = {};
431+
const stagesWithHRV: Record<string, number> = {};
432+
433+
for (const stageRecord of sleepStages) {
434+
const stageStart = new Date(stageRecord.startTime);
435+
const stageEnd = new Date(stageRecord.endTime);
436+
const stage = stageRecord.stage;
437+
438+
const matchingHr = hrSamples.filter((hr) => {
439+
const t = new Date(hr.time);
440+
return t >= stageStart && t <= stageEnd;
441+
});
442+
stagesWithHR[stage] = (stagesWithHR[stage] || 0) + matchingHr.length;
443+
444+
const matchingHrv = hrvSamples.filter((hrv) => {
445+
const t = new Date(hrv.time);
446+
return t >= stageStart && t <= stageEnd;
447+
});
448+
stagesWithHRV[stage] = (stagesWithHRV[stage] || 0) + matchingHrv.length;
449+
}
450+
451+
report.matching.stagesWithHR = stagesWithHR;
452+
report.matching.stagesWithHRV = stagesWithHRV;
453+
454+
report.model = await loadModel();
455+
} catch (error) {
456+
errors.push(`Error: ${error}`);
457+
}
458+
459+
report.errors = errors;
460+
return report;
461+
}
462+
463+
export function formatDebugReport(report: DebugReport): string {
464+
const lines: string[] = [];
465+
466+
lines.push(`=== Sleep Stage Learning Debug Report ===`);
467+
lines.push(`Time: ${report.timestamp}`);
468+
lines.push(`Platform: ${report.platform}`);
469+
lines.push('');
470+
471+
lines.push(`--- Sleep Stages ---`);
472+
lines.push(`Total stages: ${report.sleepStages.total}`);
473+
lines.push(`By stage: ${JSON.stringify(report.sleepStages.byStage)}`);
474+
if (report.sleepStages.samples.length > 0) {
475+
lines.push(`Recent samples:`);
476+
for (const s of report.sleepStages.samples.slice(0, 5)) {
477+
lines.push(` ${s.stage}: ${s.durationMin}min (${s.start})`);
478+
}
479+
}
480+
lines.push('');
481+
482+
lines.push(`--- Heart Rate ---`);
483+
lines.push(`Total samples: ${report.heartRate.total}`);
484+
if (report.heartRate.oldest && report.heartRate.newest) {
485+
lines.push(`Range: ${report.heartRate.oldest} to ${report.heartRate.newest}`);
486+
}
487+
if (report.heartRate.samples.length > 0) {
488+
lines.push(`Recent: ${report.heartRate.samples.map((s) => s.bpm).join(', ')} bpm`);
489+
}
490+
lines.push('');
491+
492+
lines.push(`--- HRV ---`);
493+
lines.push(`Total samples: ${report.hrv.total}`);
494+
if (report.hrv.oldest && report.hrv.newest) {
495+
lines.push(`Range: ${report.hrv.oldest} to ${report.hrv.newest}`);
496+
}
497+
if (report.hrv.samples.length > 0) {
498+
lines.push(`Recent: ${report.hrv.samples.map((s) => s.ms).join(', ')} ms`);
499+
}
500+
lines.push('');
501+
502+
lines.push(`--- Matching (HR/HRV samples per sleep stage) ---`);
503+
lines.push(`HR per stage: ${JSON.stringify(report.matching.stagesWithHR)}`);
504+
lines.push(`HRV per stage: ${JSON.stringify(report.matching.stagesWithHRV)}`);
505+
lines.push('');
506+
507+
lines.push(`--- Learned Model ---`);
508+
if (report.model) {
509+
lines.push(`Last updated: ${report.model.lastUpdated}`);
510+
lines.push(`Nights analyzed: ${report.model.nightsAnalyzed}`);
511+
for (const [stage, profile] of Object.entries(report.model.profiles)) {
512+
if (profile && stage !== 'any') {
513+
lines.push(
514+
` ${stage}: HR=${profile.hrMean.toFixed(1)}±${profile.hrStd.toFixed(1)}, HRV=${profile.hrvMean.toFixed(1)}±${profile.hrvStd.toFixed(1)} (n=${profile.sampleCount})`
515+
);
516+
}
517+
}
518+
} else {
519+
lines.push('No model loaded');
520+
}
521+
lines.push('');
522+
523+
if (report.errors.length > 0) {
524+
lines.push(`--- Errors ---`);
525+
for (const e of report.errors) {
526+
lines.push(` ${e}`);
527+
}
528+
}
529+
530+
return lines.join('\n');
531+
}

0 commit comments

Comments
 (0)