feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {StyleSheet, Text, TextInput, View} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../theme';
|
||||
import {PrimaryButton} from '../ppg/components/PrimaryButton';
|
||||
import {
|
||||
cardiacMirrorPatternInfo,
|
||||
targetAtElapsed,
|
||||
} from './cardiacMirrorEngine';
|
||||
import {cardiacMirrorNative} from './cardiacMirrorService';
|
||||
import type {CardiacMirrorMode} from './cardiacMirrorTypes';
|
||||
|
||||
interface CardiacMirrorScreenProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function CardiacMirrorScreen({
|
||||
onBack,
|
||||
}: CardiacMirrorScreenProps): React.JSX.Element {
|
||||
const [mode, setMode] = useState<CardiacMirrorMode>('realtime');
|
||||
const [restingBpm, setRestingBpm] = useState('');
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [visualPulse, setVisualPulse] = useState(false);
|
||||
const [watchReachable, setWatchReachable] = useState(false);
|
||||
const [watchBpm, setWatchBpm] = useState<number | null>(null);
|
||||
const [watchStartBpm, setWatchStartBpm] = useState<number | null>(null);
|
||||
const watchStartBpmRef = useRef<number | null>(null);
|
||||
const lastHeartbeatTimestampRef = useRef<number | null>(null);
|
||||
const nextPulseAtRef = useRef(0);
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pulseResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startBpm = Number(restingBpm);
|
||||
const effectiveStartBpm = mode === 'realtime' ? watchStartBpm ?? 0 : startBpm;
|
||||
const hasValidStartBpm = Number.isFinite(effectiveStartBpm) && effectiveStartBpm >= 40 && effectiveStartBpm <= 180;
|
||||
const target = useMemo(
|
||||
() => (hasValidStartBpm ? targetAtElapsed(effectiveStartBpm, elapsedSeconds) : null),
|
||||
[effectiveStartBpm, elapsedSeconds, hasValidStartBpm],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (pulseTimerRef.current !== null) {
|
||||
clearInterval(pulseTimerRef.current);
|
||||
pulseTimerRef.current = null;
|
||||
}
|
||||
if (pulseResetTimerRef.current !== null) {
|
||||
clearTimeout(pulseResetTimerRef.current);
|
||||
pulseResetTimerRef.current = null;
|
||||
}
|
||||
cardiacMirrorNative.stop();
|
||||
setIsRunning(false);
|
||||
setWatchBpm(null);
|
||||
setWatchStartBpm(null);
|
||||
watchStartBpmRef.current = null;
|
||||
lastHeartbeatTimestampRef.current = null;
|
||||
}, []);
|
||||
|
||||
const emitPulse = useCallback(() => {
|
||||
cardiacMirrorNative.pulse();
|
||||
setVisualPulse(true);
|
||||
if (pulseResetTimerRef.current !== null) {
|
||||
clearTimeout(pulseResetTimerRef.current);
|
||||
}
|
||||
pulseResetTimerRef.current = setTimeout(() => setVisualPulse(false), 180);
|
||||
}, []);
|
||||
|
||||
useEffect(() => stop, [stop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'realtime') return undefined;
|
||||
cardiacMirrorNative.startWatchSession();
|
||||
const watchPoller = setInterval(() => {
|
||||
setWatchReachable(cardiacMirrorNative.watchReachable());
|
||||
const heartbeat = cardiacMirrorNative.latestHeartbeat();
|
||||
if (
|
||||
heartbeat === undefined ||
|
||||
heartbeat.timestampSeconds === lastHeartbeatTimestampRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastHeartbeatTimestampRef.current = heartbeat.timestampSeconds;
|
||||
setWatchBpm(heartbeat.bpm);
|
||||
if (watchStartBpmRef.current === null) {
|
||||
watchStartBpmRef.current = heartbeat.bpm;
|
||||
setWatchStartBpm(heartbeat.bpm);
|
||||
const startedAtMs = Date.now();
|
||||
nextPulseAtRef.current = startedAtMs;
|
||||
cardiacMirrorNative.start(heartbeat.bpm);
|
||||
setIsRunning(true);
|
||||
pulseTimerRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - startedAtMs) / 1000;
|
||||
setElapsedSeconds(elapsed);
|
||||
const reconstructedTarget = targetAtElapsed(heartbeat.bpm, elapsed);
|
||||
const currentTime = Date.now();
|
||||
if (currentTime >= nextPulseAtRef.current) {
|
||||
emitPulse();
|
||||
nextPulseAtRef.current =
|
||||
currentTime + reconstructedTarget.intervalSeconds * 1000;
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(watchPoller);
|
||||
}, [emitPulse, mode]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!hasValidStartBpm || mode !== 'estimation') return;
|
||||
cardiacMirrorNative.start(startBpm);
|
||||
const now = Date.now();
|
||||
nextPulseAtRef.current = now;
|
||||
setElapsedSeconds(0);
|
||||
setIsRunning(true);
|
||||
pulseTimerRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - now) / 1000;
|
||||
setElapsedSeconds(elapsed);
|
||||
const currentTarget = targetAtElapsed(startBpm, elapsed);
|
||||
const currentTime = Date.now();
|
||||
if (currentTime >= nextPulseAtRef.current) {
|
||||
emitPulse();
|
||||
nextPulseAtRef.current = currentTime + currentTarget.intervalSeconds * 1000;
|
||||
}
|
||||
}, 50);
|
||||
}, [emitPulse, hasValidStartBpm, mode, startBpm]);
|
||||
|
||||
const pattern = cardiacMirrorPatternInfo;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<PrimaryButton label="Retour" variant="ghost" onPress={onBack} />
|
||||
<Text style={styles.kicker}>MODULE 7 · MIROIR CARDIAQUE</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Sentir son cœur battre en miroir.</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
La fréquence mesurée sert de point de départ à un rythme visuel et
|
||||
haptique reconstruit, puis ralenti de 1 à 2 BPM par minute.
|
||||
</Text>
|
||||
|
||||
<View style={styles.modeRow}>
|
||||
{(['realtime', 'estimation'] as const).map(value => (
|
||||
<PrimaryButton
|
||||
key={value}
|
||||
label={value === 'realtime' ? 'Temps réel · Watch+' : 'Estimation · iPhone'}
|
||||
variant={mode === value ? 'secondary' : 'ghost'}
|
||||
onPress={() => {
|
||||
stop();
|
||||
setMode(value);
|
||||
}}
|
||||
style={styles.modeButton}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.statusCard}>
|
||||
<View style={[styles.pulse, visualPulse && styles.pulseActive]} />
|
||||
<View style={styles.statusCopy}>
|
||||
<Text style={styles.statusTitle}>
|
||||
{mode === 'realtime' ? 'Mode Temps réel' : 'Mode Estimation'}
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
{mode === 'realtime'
|
||||
? watchReachable
|
||||
? 'Apple Watch connectée : ses mises à jour de fréquence pilotent un rythme reconstruit sur l’iPhone.'
|
||||
: 'En attente d’une Apple Watch connectée. Le miroir démarrera après une fréquence valide.'
|
||||
: 'L’iPhone utilise une fréquence de départ saisie manuellement, sans prétendre mesurer chaque battement.'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{mode === 'estimation' && (
|
||||
<View style={styles.inputCard}>
|
||||
<Text style={styles.inputLabel}>Fréquence de départ (BPM)</Text>
|
||||
<TextInput
|
||||
value={restingBpm}
|
||||
onChangeText={setRestingBpm}
|
||||
keyboardType="number-pad"
|
||||
placeholder="ex. 68"
|
||||
placeholderTextColor={colors.muted}
|
||||
style={styles.input}
|
||||
accessibilityLabel="Fréquence cardiaque de départ en battements par minute"
|
||||
/>
|
||||
<Text style={styles.helperText}>
|
||||
Utilisez votre fréquence au repos habituelle ou une valeur mesurée juste avant.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.metricsCard}>
|
||||
<View>
|
||||
<Text style={styles.metricLabel}>Cible actuelle</Text>
|
||||
<Text style={styles.metricValue}>
|
||||
{target === null ? '—' : `${Math.round(target.bpm)} BPM`}
|
||||
</Text>
|
||||
</View>
|
||||
{mode === 'realtime' && (
|
||||
<View>
|
||||
<Text style={styles.metricLabel}>Watch</Text>
|
||||
<Text style={styles.metricValue}>{watchBpm === null ? '—' : `${Math.round(watchBpm)} BPM`}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View>
|
||||
<Text style={styles.metricLabel}>Durée</Text>
|
||||
<Text style={styles.metricValue}>{Math.floor(elapsedSeconds)} s</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<PrimaryButton
|
||||
label={isRunning ? 'Arrêter le miroir' : 'Démarrer le miroir'}
|
||||
onPress={isRunning ? stop : start}
|
||||
disabled={mode === 'realtime' || !hasValidStartBpm}
|
||||
/>
|
||||
{mode === 'realtime' && (
|
||||
<Text style={styles.notice}>
|
||||
La Watch mesure sans vibrer. L’iPhone produit ensuite le visuel et la
|
||||
texture haptique ; ce n’est pas chaque battement biologique exact.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={styles.ipNote}>
|
||||
Pattern {pattern.id} · v{pattern.version} · rythme reconstruit
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flex: 1, padding: spacing.lg, backgroundColor: colors.background},
|
||||
header: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'},
|
||||
kicker: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1},
|
||||
title: {color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '800', marginTop: spacing.xl},
|
||||
subtitle: {color: colors.muted, fontSize: 16, lineHeight: 24, marginTop: spacing.md},
|
||||
modeRow: {flexDirection: 'row', gap: spacing.xs, marginTop: spacing.lg},
|
||||
modeButton: {flex: 1, minHeight: 52, paddingHorizontal: spacing.sm},
|
||||
statusCard: {flexDirection: 'row', alignItems: 'center', marginTop: spacing.lg, padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surfaceElevated},
|
||||
pulse: {width: 42, height: 42, borderRadius: 21, backgroundColor: colors.border, marginRight: spacing.md},
|
||||
pulseActive: {backgroundColor: colors.accent, transform: [{scale: 1.18}]},
|
||||
statusCopy: {flex: 1},
|
||||
statusTitle: {color: colors.text, fontSize: 17, fontWeight: '800'},
|
||||
statusText: {color: colors.muted, lineHeight: 19, marginTop: spacing.xs},
|
||||
inputCard: {marginTop: spacing.md, padding: spacing.md, borderRadius: radii.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface},
|
||||
inputLabel: {color: colors.text, fontWeight: '700'},
|
||||
input: {color: colors.text, fontSize: 28, fontWeight: '800', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border},
|
||||
helperText: {color: colors.muted, fontSize: 12, lineHeight: 17, marginTop: spacing.xs},
|
||||
metricsCard: {flexDirection: 'row', justifyContent: 'space-around', marginVertical: spacing.lg, padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surface},
|
||||
metricLabel: {color: colors.muted, fontSize: 12},
|
||||
metricValue: {color: colors.text, fontSize: 25, fontWeight: '800', marginTop: spacing.xs},
|
||||
notice: {color: colors.medium, fontSize: 12, lineHeight: 17, textAlign: 'center', marginTop: spacing.sm},
|
||||
ipNote: {color: colors.muted, fontSize: 10, textAlign: 'center', marginTop: 'auto'},
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {colors, radii, spacing} from '../../theme';
|
||||
import {PrimaryButton} from '../ppg/components/PrimaryButton';
|
||||
import {cardiacMirrorNative} from './cardiacMirrorService';
|
||||
import {ProtocolInfoSheet} from './ProtocolInfoSheet';
|
||||
import {activeRegulationPhase} from './regulationPhaseEngine';
|
||||
import {
|
||||
formatProtocolDuration,
|
||||
getRegulationProtocol,
|
||||
HAPTIC_REGULATION_PROTOCOLS,
|
||||
type HapticRegulationProtocol,
|
||||
type HapticRegulationProtocolKind,
|
||||
} from './regulationProtocols';
|
||||
|
||||
interface HapticWaveExerciseScreenProps {
|
||||
onBack: () => void;
|
||||
initialProtocolKind?: HapticRegulationProtocolKind;
|
||||
onCompleted?: (summary: HapticExerciseSummary) => void;
|
||||
onCancelled?: (summary: HapticExerciseSummary) => void;
|
||||
onRequestStart?: (protocol: HapticRegulationProtocol) => boolean;
|
||||
}
|
||||
|
||||
export interface HapticExerciseSummary {
|
||||
protocolKind: HapticRegulationProtocolKind;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
elapsedSeconds: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function HapticWaveExerciseScreen({
|
||||
onBack,
|
||||
initialProtocolKind = 'coherence55',
|
||||
onCompleted,
|
||||
onCancelled,
|
||||
onRequestStart,
|
||||
}: HapticWaveExerciseScreenProps): React.JSX.Element {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [selectedKind, setSelectedKind] =
|
||||
useState<HapticRegulationProtocolKind>(initialProtocolKind);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [hapticsAvailable, setHapticsAvailable] = useState(true);
|
||||
const [infoProtocol, setInfoProtocol] = useState<HapticRegulationProtocol | null>(null);
|
||||
const waveScale = useRef(new Animated.Value(0.7)).current;
|
||||
const waveOpacity = useRef(new Animated.Value(0.55)).current;
|
||||
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const sessionStartedAtRef = useRef(0);
|
||||
|
||||
const selectedProtocol = useMemo(
|
||||
() => getRegulationProtocol(selectedKind),
|
||||
[selectedKind],
|
||||
);
|
||||
const phase = activeRegulationPhase(selectedProtocol, elapsedSeconds);
|
||||
const progress = Math.min(
|
||||
1,
|
||||
elapsedSeconds / selectedProtocol.durationSeconds,
|
||||
);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
clearTimer();
|
||||
animationRef.current?.stop();
|
||||
animationRef.current = null;
|
||||
waveScale.stopAnimation();
|
||||
waveOpacity.stopAnimation();
|
||||
waveScale.setValue(0.7);
|
||||
waveOpacity.setValue(0.55);
|
||||
cardiacMirrorNative.stopRegulationProtocol();
|
||||
setRunning(false);
|
||||
setElapsedSeconds(0);
|
||||
}, [clearTimer, waveOpacity, waveScale]);
|
||||
|
||||
const summary = useCallback(
|
||||
(completed: boolean, elapsed: number): HapticExerciseSummary => ({
|
||||
protocolKind: selectedKind,
|
||||
startedAt: new Date(sessionStartedAtRef.current).toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
elapsedSeconds: Math.max(0, elapsed),
|
||||
completed,
|
||||
}),
|
||||
[selectedKind],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setHapticsAvailable(cardiacMirrorNative.isSupported());
|
||||
return stop;
|
||||
}, [stop]);
|
||||
|
||||
const startWaveAnimation = useCallback(
|
||||
(protocol: HapticRegulationProtocol) => {
|
||||
const sequence = protocol.phases[0]?.kind === 'anchor'
|
||||
? Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.timing(waveScale, {
|
||||
toValue: 1.03,
|
||||
duration: 2600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(waveOpacity, {
|
||||
toValue: 0.82,
|
||||
duration: 2600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
Animated.parallel([
|
||||
Animated.timing(waveScale, {
|
||||
toValue: 0.76,
|
||||
duration: 3400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(waveOpacity, {
|
||||
toValue: 0.48,
|
||||
duration: 3400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
])
|
||||
: Animated.sequence(protocol.phases.map(phaseItem => {
|
||||
const expanding = phaseItem.kind === 'inhale';
|
||||
const contracting = phaseItem.kind === 'exhale';
|
||||
if (!expanding && !contracting) {
|
||||
return Animated.delay(phaseItem.durationSeconds * 1000);
|
||||
}
|
||||
return Animated.parallel([
|
||||
Animated.timing(waveScale, {
|
||||
toValue: expanding ? 1.12 : 0.7,
|
||||
duration: phaseItem.durationSeconds * 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(waveOpacity, {
|
||||
toValue: expanding ? 0.92 : 0.48,
|
||||
duration: phaseItem.durationSeconds * 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
}));
|
||||
const animation = Animated.loop(sequence);
|
||||
animationRef.current = animation;
|
||||
animation.start();
|
||||
},
|
||||
[waveOpacity, waveScale],
|
||||
);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (running) return;
|
||||
if (onRequestStart?.(selectedProtocol) === true) return;
|
||||
const nativeDuration = cardiacMirrorNative.playRegulationProtocol(
|
||||
selectedProtocol.kind,
|
||||
);
|
||||
if (nativeDuration <= 0) {
|
||||
setHapticsAvailable(false);
|
||||
}
|
||||
|
||||
sessionStartedAtRef.current = Date.now();
|
||||
setElapsedSeconds(0);
|
||||
setRunning(true);
|
||||
startWaveAnimation(selectedProtocol);
|
||||
timerRef.current = setInterval(() => {
|
||||
const elapsed = (Date.now() - sessionStartedAtRef.current) / 1000;
|
||||
if (elapsed >= selectedProtocol.durationSeconds) {
|
||||
const completedSummary = summary(true, elapsed);
|
||||
stop();
|
||||
onCompleted?.(completedSummary);
|
||||
return;
|
||||
}
|
||||
setElapsedSeconds(elapsed);
|
||||
}, 100);
|
||||
}, [onCompleted, onRequestStart, running, selectedProtocol, startWaveAnimation, stop, summary]);
|
||||
|
||||
const cancelExercise = useCallback(() => {
|
||||
if (!running) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
const elapsed = (Date.now() - sessionStartedAtRef.current) / 1000;
|
||||
const cancelledSummary = summary(false, elapsed);
|
||||
stop();
|
||||
onCancelled?.(cancelledSummary);
|
||||
onBack();
|
||||
}, [onBack, onCancelled, running, stop, summary]);
|
||||
|
||||
const phaseLabel = phase.phase.label;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{paddingTop: insets.top + spacing.md},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.header}>
|
||||
<PrimaryButton label="Retour" variant="ghost" onPress={cancelExercise} />
|
||||
<View style={styles.brandBlock}>
|
||||
<Text style={styles.brand}>HAPTIKA</Text>
|
||||
<Text style={styles.kicker}>RÉGULATION HAPTIQUE</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!running ? (
|
||||
<>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.eyebrow}>CHOISISSEZ VOTRE RYTHME</Text>
|
||||
<Text style={styles.title}>Le calme au creux de la main.</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Choisissez un rythme, gardez l’iPhone en main et laissez les
|
||||
vibrations guider votre respiration.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Choisir un exercice</Text>
|
||||
<Text style={styles.sectionCount}>{HAPTIC_REGULATION_PROTOCOLS.length} RYTHMES</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.protocolList}>
|
||||
{HAPTIC_REGULATION_PROTOCOLS.map((protocol, index) => {
|
||||
const selected = protocol.kind === selectedKind;
|
||||
return (
|
||||
<Pressable
|
||||
key={protocol.kind}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{selected}}
|
||||
onPress={() => setSelectedKind(protocol.kind)}
|
||||
style={({pressed}) => [
|
||||
styles.protocolCard,
|
||||
selected && styles.protocolCardSelected,
|
||||
pressed && styles.protocolCardPressed,
|
||||
]}>
|
||||
<View style={[styles.protocolIndex, selected && styles.protocolIndexSelected]}>
|
||||
<Text style={[styles.protocolIndexText, selected && styles.protocolIndexTextSelected]}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.protocolCopy}>
|
||||
<View style={styles.protocolHeadingRow}>
|
||||
<Text style={styles.protocolEyebrow}>{protocol.family === 'breathe' ? 'RESPIRER' : protocol.family === 'ground' ? 'ANCRER' : 'RECENTRER'} · {protocol.eyebrow}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel={`Informations sur ${protocol.title}`} hitSlop={10} onPress={() => setInfoProtocol(protocol)} style={styles.infoButton}>
|
||||
<Text style={styles.infoButtonText}>i</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.protocolTitle}>{protocol.title}</Text>
|
||||
{selected ? (
|
||||
<Text style={styles.protocolDescription}>{protocol.description}</Text>
|
||||
) : null}
|
||||
<View style={styles.protocolMeta}>
|
||||
<Text style={styles.protocolMetaText}>
|
||||
{formatProtocolDuration(protocol.durationSeconds)}
|
||||
</Text>
|
||||
<View style={styles.metaDot} />
|
||||
<Text style={styles.protocolMetaText}>
|
||||
{protocol.mode === 'livePPG' ? 'caméra · sans vibration' : `vibration ${protocol.intensity}`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.selectionDot, selected && styles.selectionDotSelected]} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<PrimaryButton
|
||||
label={`Commencer · ${formatProtocolDuration(selectedProtocol.durationSeconds)}`}
|
||||
onPress={start}
|
||||
disabled={false}
|
||||
/>
|
||||
{!hapticsAvailable && selectedProtocol.mode === 'haptic' && (
|
||||
<Text style={styles.unavailableText}>
|
||||
Les vibrations ne sont pas disponibles. Le guide visuel reste utilisable.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.activeSession}>
|
||||
<Text style={styles.activeEyebrow}>{selectedProtocol.eyebrow}</Text>
|
||||
<Text style={styles.activeTitle}>{selectedProtocol.title}</Text>
|
||||
<Text style={styles.activeSubtitle}>Gardez le téléphone dans votre main.</Text>
|
||||
|
||||
<View style={styles.waveArea} accessibilityLabel="Onde visuelle de régulation">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.wave,
|
||||
styles.waveOuter,
|
||||
{opacity: waveOpacity, transform: [{scale: waveScale}]},
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.wave,
|
||||
styles.waveMiddle,
|
||||
{opacity: waveOpacity, transform: [{scale: waveScale}]},
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.wave,
|
||||
styles.waveInner,
|
||||
{transform: [{scale: waveScale}]},
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.phaseLabel}>{phaseLabel}</Text>
|
||||
<Text style={styles.phaseCountdown}>{phase.remainingSeconds}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressHeader}>
|
||||
<Text style={styles.progressLabel}>PROGRESSION</Text>
|
||||
<Text style={styles.progressValue}>{Math.round(progress * 100)} %</Text>
|
||||
</View>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, {width: `${progress * 100}%`}]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.sessionNote}>
|
||||
<View style={styles.sessionNoteDot} />
|
||||
<Text style={styles.sessionNoteText}>
|
||||
Ce protocole accompagne la régulation ressentie. Il ne mesure pas
|
||||
et ne diagnostique pas votre niveau de stress.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<PrimaryButton
|
||||
label="Arrêter"
|
||||
variant="secondary"
|
||||
onPress={cancelExercise}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProtocolInfoSheet protocol={infoProtocol} onClose={() => setInfoProtocol(null)} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {flex: 1, backgroundColor: colors.background},
|
||||
content: {padding: spacing.lg, paddingBottom: spacing.xxl, gap: spacing.md},
|
||||
header: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'},
|
||||
brandBlock: {alignItems: 'flex-end'},
|
||||
brand: {color: colors.text, fontSize: 15, fontWeight: '900', letterSpacing: 2.2},
|
||||
kicker: {color: colors.accent, fontSize: 9, fontWeight: '800', letterSpacing: 1.2, marginTop: 3},
|
||||
hero: {paddingTop: spacing.sm, paddingBottom: spacing.sm},
|
||||
eyebrow: {color: colors.accent, fontSize: 11, fontWeight: '800', letterSpacing: 1.8},
|
||||
title: {color: colors.text, fontSize: 34, lineHeight: 39, fontWeight: '800', letterSpacing: -1.2, marginTop: spacing.sm, maxWidth: 330},
|
||||
subtitle: {color: colors.muted, fontSize: 16, lineHeight: 24, marginTop: spacing.md, maxWidth: 350},
|
||||
heroOrb: {alignSelf: 'center', width: 182, height: 182, borderRadius: 91, borderWidth: 1, borderColor: '#174E78', alignItems: 'center', justifyContent: 'center', backgroundColor: '#041128', marginTop: spacing.xl},
|
||||
heroOrbMiddle: {width: 126, height: 126, borderRadius: 63, borderWidth: 1, borderColor: '#287BAA', alignItems: 'center', justifyContent: 'center', backgroundColor: '#082345'},
|
||||
heroOrbCore: {width: 66, height: 66, borderRadius: 33, backgroundColor: colors.accent, borderWidth: 8, borderColor: '#B7ECFF'},
|
||||
sectionHeader: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'},
|
||||
sectionTitle: {color: colors.text, fontSize: 24, fontWeight: '800'},
|
||||
sectionCount: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.2},
|
||||
protocolList: {gap: spacing.sm},
|
||||
protocolCard: {minHeight: 96, flexDirection: 'row', alignItems: 'flex-start', padding: spacing.md, borderRadius: radii.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface},
|
||||
protocolCardSelected: {borderColor: colors.accent, backgroundColor: colors.surfaceElevated},
|
||||
protocolCardPressed: {opacity: 0.82},
|
||||
protocolIndex: {width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0A2746', borderWidth: 1, borderColor: colors.border},
|
||||
protocolIndexSelected: {backgroundColor: colors.accent, borderColor: colors.accent},
|
||||
protocolIndexText: {color: colors.muted, fontSize: 11, fontWeight: '800'},
|
||||
protocolIndexTextSelected: {color: colors.background},
|
||||
protocolCopy: {flex: 1, paddingHorizontal: spacing.md},
|
||||
protocolHeadingRow: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'},
|
||||
infoButton: {width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.border},
|
||||
infoButtonText: {color: colors.accent, fontSize: 16, fontWeight: '800'},
|
||||
protocolEyebrow: {color: colors.accent, fontSize: 9, fontWeight: '800', letterSpacing: 1.2},
|
||||
protocolTitle: {color: colors.text, fontSize: 20, fontWeight: '800', marginTop: 3},
|
||||
protocolDescription: {color: colors.muted, fontSize: 13, lineHeight: 18, marginTop: spacing.xs},
|
||||
protocolMeta: {flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm},
|
||||
protocolMetaText: {color: '#C8E5F8', fontSize: 11},
|
||||
metaDot: {width: 3, height: 3, borderRadius: 2, backgroundColor: colors.accent, marginHorizontal: spacing.xs},
|
||||
selectionDot: {width: 18, height: 18, borderRadius: 9, borderWidth: 1, borderColor: colors.border, marginTop: 3},
|
||||
selectionDotSelected: {borderWidth: 5, borderColor: colors.accent, backgroundColor: colors.background},
|
||||
startPanel: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: spacing.md, borderRadius: radii.md, backgroundColor: '#071A33'},
|
||||
startPanelLabel: {color: colors.accent, fontSize: 9, fontWeight: '800', letterSpacing: 1.2},
|
||||
startPanelTitle: {color: colors.text, fontSize: 17, fontWeight: '800', marginTop: 3},
|
||||
startPanelDuration: {color: colors.text, fontSize: 15, fontWeight: '700'},
|
||||
unavailableText: {color: colors.poor, textAlign: 'center', lineHeight: 19},
|
||||
activeSession: {paddingTop: spacing.xl, gap: spacing.md},
|
||||
activeEyebrow: {color: colors.accent, fontSize: 11, fontWeight: '800', letterSpacing: 1.8, textAlign: 'center'},
|
||||
activeTitle: {color: colors.text, fontSize: 36, fontWeight: '800', textAlign: 'center'},
|
||||
activeSubtitle: {color: colors.muted, fontSize: 15, textAlign: 'center'},
|
||||
waveArea: {height: 390, alignItems: 'center', justifyContent: 'center', overflow: 'hidden'},
|
||||
wave: {position: 'absolute', borderRadius: 999, borderWidth: 1},
|
||||
waveOuter: {width: 310, height: 310, borderColor: '#1B5B86', backgroundColor: '#041229'},
|
||||
waveMiddle: {width: 230, height: 230, borderColor: '#2B8ABC', backgroundColor: '#082143'},
|
||||
waveInner: {width: 142, height: 142, borderColor: '#B7ECFF', borderWidth: 2, backgroundColor: colors.accentSoft},
|
||||
phaseLabel: {color: colors.text, fontSize: 23, fontWeight: '800', textAlign: 'center', maxWidth: 170},
|
||||
phaseCountdown: {color: colors.accent, fontSize: 34, fontWeight: '300', fontVariant: ['tabular-nums'], marginTop: spacing.xs},
|
||||
progressHeader: {flexDirection: 'row', justifyContent: 'space-between'},
|
||||
progressLabel: {color: colors.muted, fontSize: 10, fontWeight: '800', letterSpacing: 1.2},
|
||||
progressValue: {color: colors.accent, fontWeight: '800', fontVariant: ['tabular-nums']},
|
||||
progressTrack: {height: 6, borderRadius: 3, backgroundColor: colors.surfaceElevated, overflow: 'hidden'},
|
||||
progressFill: {height: 6, borderRadius: 3, backgroundColor: colors.accent},
|
||||
sessionNote: {flexDirection: 'row', padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surface},
|
||||
sessionNoteDot: {width: 8, height: 8, borderRadius: 4, backgroundColor: colors.accent, marginTop: 5, marginRight: spacing.sm},
|
||||
sessionNoteText: {flex: 1, color: colors.muted, fontSize: 12, lineHeight: 18},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import {Modal, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../theme';
|
||||
import type {HapticRegulationProtocol} from './regulationProtocols';
|
||||
|
||||
interface ProtocolInfoSheetProps {
|
||||
protocol: HapticRegulationProtocol | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProtocolInfoSheet({protocol, onClose}: ProtocolInfoSheetProps): React.JSX.Element {
|
||||
return (
|
||||
<Modal visible={protocol !== null} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.backdrop}>
|
||||
<Pressable accessibilityLabel="Fermer" style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||
{protocol !== null ? (
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.handle} />
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.eyebrow}>À PROPOS DU PROTOCOLE</Text>
|
||||
<Text style={styles.title}>{protocol.title}</Text>
|
||||
<InfoSection title="Ce qu’est ce protocole" body={protocol.education.definition} />
|
||||
<InfoSection title="Bénéfice documenté" body={protocol.education.documentedBenefit} />
|
||||
<InfoSection title="Ce que fait Haptika" body={protocol.education.moduleAction} />
|
||||
<View style={styles.sourceBlock}>
|
||||
<Text style={styles.sourceTitle}>SOURCES ET CADRE</Text>
|
||||
{protocol.education.sources.map(source => (
|
||||
<Text key={source} style={styles.sourceText}>• {source}</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.disclaimer}>
|
||||
Outil de bien-être non médical. Il ne pose aucun diagnostic et ne remplace pas un professionnel de santé.
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" onPress={onClose} style={styles.button}>
|
||||
<Text style={styles.buttonText}>J’ai compris</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoSection({title, body}: {title: string; body: string}): React.JSX.Element {
|
||||
return <View style={styles.section}><Text style={styles.sectionTitle}>{title}</Text><Text style={styles.body}>{body}</Text></View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,5,15,0.72)'},
|
||||
sheet: {maxHeight: '86%', backgroundColor: colors.surface, borderTopLeftRadius: 30, borderTopRightRadius: 30, borderWidth: 1, borderColor: colors.border, overflow: 'hidden'},
|
||||
handle: {width: 42, height: 5, borderRadius: 3, backgroundColor: colors.border, alignSelf: 'center', marginTop: spacing.sm},
|
||||
content: {padding: spacing.lg, paddingBottom: spacing.xxl, gap: spacing.md},
|
||||
eyebrow: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.7},
|
||||
title: {color: colors.text, fontSize: 30, fontWeight: '800'},
|
||||
section: {gap: spacing.xs, paddingTop: spacing.sm},
|
||||
sectionTitle: {color: colors.text, fontSize: 17, fontWeight: '800'},
|
||||
body: {color: colors.muted, fontSize: 15, lineHeight: 22},
|
||||
sourceBlock: {padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surfaceElevated, gap: spacing.xs},
|
||||
sourceTitle: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.2},
|
||||
sourceText: {color: colors.muted, fontSize: 12, lineHeight: 18},
|
||||
disclaimer: {color: colors.muted, fontSize: 12, lineHeight: 18},
|
||||
button: {minHeight: 54, borderRadius: radii.md, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.accent},
|
||||
buttonText: {color: colors.background, fontSize: 16, fontWeight: '900'},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
decelerationBpmPerMinute,
|
||||
targetBpmAtElapsed,
|
||||
targetForHeartbeat,
|
||||
} from './cardiacMirrorEngine';
|
||||
|
||||
describe('cardiac mirror engine', () => {
|
||||
it('uses a dynamic starting heart rate', () => {
|
||||
expect(targetBpmAtElapsed(72, 0)).toBe(72);
|
||||
expect(targetBpmAtElapsed(72, 60)).toBeCloseTo(70.8, 4);
|
||||
});
|
||||
|
||||
it('keeps the deceleration in the specified training band', () => {
|
||||
expect(decelerationBpmPerMinute(45)).toBe(1);
|
||||
expect(decelerationBpmPerMinute(72)).toBeCloseTo(1.2, 4);
|
||||
expect(decelerationBpmPerMinute(150)).toBe(2);
|
||||
});
|
||||
|
||||
it('never creates a real-time beat without a valid Watch heartbeat', () => {
|
||||
expect(
|
||||
targetForHeartbeat(72, 60, {
|
||||
timestampSeconds: 1,
|
||||
bpm: 78,
|
||||
source: 'watch',
|
||||
}),
|
||||
).toMatchObject({bpm: 70.8});
|
||||
expect(
|
||||
targetForHeartbeat(72, 60, {
|
||||
timestampSeconds: 1,
|
||||
bpm: 30,
|
||||
source: 'watch',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
CardiacMirrorHeartbeat,
|
||||
CardiacMirrorTarget,
|
||||
} from './cardiacMirrorTypes';
|
||||
|
||||
export const CARDIAC_MIRROR_PATTERN_ID = 'HAPTIKA.CARDIAC_MIRROR_BEAT';
|
||||
export const CARDIAC_MIRROR_PATTERN_VERSION = '1.0.0';
|
||||
export const CARDIAC_MIRROR_MINIMUM_BPM = 40;
|
||||
|
||||
export const cardiacMirrorPatternInfo = {
|
||||
id: CARDIAC_MIRROR_PATTERN_ID,
|
||||
version: CARDIAC_MIRROR_PATTERN_VERSION,
|
||||
} as const;
|
||||
|
||||
function finitePositive(value: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? value : 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decelerates faster from a higher starting rate while staying in the
|
||||
* intentionally small 1–2 BPM/minute training band.
|
||||
*/
|
||||
export function decelerationBpmPerMinute(startBpm: number): number {
|
||||
const start = finitePositive(startBpm);
|
||||
return Math.min(2, Math.max(1, 1 + (start - 60) / 60));
|
||||
}
|
||||
|
||||
export function targetBpmAtElapsed(
|
||||
startBpm: number,
|
||||
elapsedSeconds: number,
|
||||
): number {
|
||||
const start = finitePositive(startBpm);
|
||||
const elapsed = Math.max(0, Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0);
|
||||
const target = start - decelerationBpmPerMinute(start) * (elapsed / 60);
|
||||
return Math.max(CARDIAC_MIRROR_MINIMUM_BPM, target);
|
||||
}
|
||||
|
||||
export function targetAtElapsed(
|
||||
startBpm: number,
|
||||
elapsedSeconds: number,
|
||||
): CardiacMirrorTarget {
|
||||
const bpm = targetBpmAtElapsed(startBpm, elapsedSeconds);
|
||||
return {
|
||||
bpm,
|
||||
intervalSeconds: 60 / bpm,
|
||||
elapsedSeconds: Math.max(0, elapsedSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeHeartbeat(
|
||||
heartbeat: CardiacMirrorHeartbeat,
|
||||
): CardiacMirrorHeartbeat | undefined {
|
||||
if (
|
||||
heartbeat.source !== 'watch' ||
|
||||
!Number.isFinite(heartbeat.timestampSeconds) ||
|
||||
!Number.isFinite(heartbeat.bpm) ||
|
||||
heartbeat.bpm < 35 ||
|
||||
heartbeat.bpm > 220
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return heartbeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Watch cadence sample updates the reconstructed target. Public workout APIs
|
||||
* do not expose a reliable biological beat event, so this function must never
|
||||
* be presented as beat-to-beat synchronization.
|
||||
*/
|
||||
export function targetForHeartbeat(
|
||||
startBpm: number,
|
||||
elapsedSeconds: number,
|
||||
heartbeat: CardiacMirrorHeartbeat,
|
||||
): CardiacMirrorTarget | undefined {
|
||||
const cleanHeartbeat = sanitizeHeartbeat(heartbeat);
|
||||
if (cleanHeartbeat === undefined) return undefined;
|
||||
|
||||
const target = targetAtElapsed(startBpm, elapsedSeconds);
|
||||
return {
|
||||
...target,
|
||||
// Keep the target below the observed rate, but do not create a sudden or
|
||||
// perceptible jump when the observed rate changes.
|
||||
bpm: Math.min(target.bpm, cleanHeartbeat.bpm),
|
||||
intervalSeconds: 60 / Math.min(target.bpm, cleanHeartbeat.bpm),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {haptikaCardiacMirror} from 'react-native-haptika-ppg';
|
||||
|
||||
import {
|
||||
CARDIAC_MIRROR_PATTERN_ID,
|
||||
CARDIAC_MIRROR_PATTERN_VERSION,
|
||||
} from './cardiacMirrorEngine';
|
||||
import type {CardiacMirrorNative} from './cardiacMirrorTypes';
|
||||
|
||||
export const cardiacMirrorNative: CardiacMirrorNative = {
|
||||
isSupported: () => haptikaCardiacMirror.isSupported(),
|
||||
patternInfo: () => {
|
||||
const pattern = haptikaCardiacMirror.patternInfo();
|
||||
return {id: pattern.id, version: pattern.version};
|
||||
},
|
||||
start: bpm => haptikaCardiacMirror.start(bpm),
|
||||
pulse: () => haptikaCardiacMirror.pulse(),
|
||||
stop: () => haptikaCardiacMirror.stop(),
|
||||
startWatchSession: () => haptikaCardiacMirror.startWatchSession(),
|
||||
watchReachable: () => haptikaCardiacMirror.watchReachable(),
|
||||
latestHeartbeat: () => {
|
||||
const heartbeat = haptikaCardiacMirror.latestHeartbeat();
|
||||
return heartbeat === undefined
|
||||
? undefined
|
||||
: {timestampSeconds: heartbeat.timestampSeconds, bpm: heartbeat.bpm, source: 'watch'};
|
||||
},
|
||||
playRegulationProtocol: kind =>
|
||||
haptikaCardiacMirror.playRegulationProtocol(kind),
|
||||
stopRegulationProtocol: () =>
|
||||
haptikaCardiacMirror.stopRegulationProtocol(),
|
||||
};
|
||||
|
||||
export const cardiacMirrorFallbackPatternInfo = {
|
||||
id: CARDIAC_MIRROR_PATTERN_ID,
|
||||
version: CARDIAC_MIRROR_PATTERN_VERSION,
|
||||
} as const;
|
||||
@@ -0,0 +1,31 @@
|
||||
export type CardiacMirrorMode = 'realtime' | 'estimation';
|
||||
|
||||
export interface CardiacMirrorHeartbeat {
|
||||
timestampSeconds: number;
|
||||
bpm: number;
|
||||
source: 'watch';
|
||||
}
|
||||
|
||||
export interface CardiacMirrorTarget {
|
||||
bpm: number;
|
||||
intervalSeconds: number;
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface CardiacMirrorPatternInfo {
|
||||
id: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface CardiacMirrorNative {
|
||||
isSupported(): boolean;
|
||||
patternInfo(): CardiacMirrorPatternInfo;
|
||||
start(targetBpm: number): void;
|
||||
pulse(): void;
|
||||
stop(): void;
|
||||
startWatchSession(): void;
|
||||
watchReachable(): boolean;
|
||||
latestHeartbeat(): CardiacMirrorHeartbeat | undefined;
|
||||
playRegulationProtocol(kind: string): number;
|
||||
stopRegulationProtocol(): void;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {StoredMeasurement} from '../ppg/storage/storageTypes';
|
||||
import {makeProcessingResult} from '../ppg/tests/syntheticMeasurements';
|
||||
import {compareRegulationMeasurements} from './regulationComparison';
|
||||
|
||||
function measurement(
|
||||
id: string,
|
||||
measuredAt: string,
|
||||
bpm: number,
|
||||
rmssd: number,
|
||||
stressIndex: number | null,
|
||||
): StoredMeasurement {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
acquisition: {cameraPosition: 'front', torchUsed: false},
|
||||
id,
|
||||
measuredAt,
|
||||
atRest: true,
|
||||
baselineEligible: true,
|
||||
cameraResult: makeProcessingResult({
|
||||
bpm: {...makeProcessingResult().bpm, bpm},
|
||||
prv: {
|
||||
...makeProcessingResult().prv,
|
||||
estimatedRmssdMs: rmssd,
|
||||
estimatedSdnnMs: rmssd + 6,
|
||||
},
|
||||
}),
|
||||
potentialStress: {
|
||||
index: stressIndex,
|
||||
confidence: 0.8,
|
||||
status: stressIndex === null ? 'baselineBuilding' : 'personalized',
|
||||
bpmContribution: null,
|
||||
prvContribution: null,
|
||||
signalQuality: 0.9,
|
||||
baselineMeasurementCount: 10,
|
||||
healthKitUsedForScore: false,
|
||||
limitations: [],
|
||||
},
|
||||
healthKitContext: null,
|
||||
selfReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('regulation before/after comparison', () => {
|
||||
test('calculates signed absolute and percentage changes', () => {
|
||||
const before = measurement('before', '2026-07-15T10:00:00.000Z', 90, 30, 70);
|
||||
const after = measurement('after', '2026-07-15T10:07:00.000Z', 81, 36, 50);
|
||||
const comparison = compareRegulationMeasurements(before, after);
|
||||
|
||||
expect(comparison.elapsedSeconds).toBe(420);
|
||||
expect(comparison.bpm).toMatchObject({delta: -9, percentChange: -10});
|
||||
expect(comparison.rmssdMs).toMatchObject({delta: 6, percentChange: 20});
|
||||
expect(comparison.potentialStressIndex?.delta).toBe(-20);
|
||||
expect(comparison.limitations).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not manufacture PRV or stress comparisons when unavailable', () => {
|
||||
const before = measurement('before', '2026-07-15T10:00:00.000Z', 90, 30, null);
|
||||
const after = measurement('after', '2026-07-15T10:07:00.000Z', 88, 32, null);
|
||||
after.cameraResult.prv.reliable = false;
|
||||
const comparison = compareRegulationMeasurements(before, after);
|
||||
|
||||
expect(comparison.rmssdMs).toBeNull();
|
||||
expect(comparison.sdnnMs).toBeNull();
|
||||
expect(comparison.potentialStressIndex).toBeNull();
|
||||
expect(comparison.limitations).toEqual(
|
||||
expect.arrayContaining(['prvUnavailable', 'stressIndexUnavailable']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {StoredMeasurement} from '../ppg/storage/storageTypes';
|
||||
import type {
|
||||
MetricComparison,
|
||||
RegulationComparison,
|
||||
RegulationComparisonLimitation,
|
||||
} from './regulationSessionTypes';
|
||||
|
||||
const finite = (value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined && Number.isFinite(value);
|
||||
|
||||
function compareMetric(before: number, after: number): MetricComparison {
|
||||
const delta = after - before;
|
||||
return {
|
||||
before,
|
||||
after,
|
||||
delta,
|
||||
percentChange: before === 0 ? null : (delta / Math.abs(before)) * 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function compareRegulationMeasurements(
|
||||
before: StoredMeasurement,
|
||||
after: StoredMeasurement,
|
||||
): RegulationComparison {
|
||||
const limitations = new Set<RegulationComparisonLimitation>();
|
||||
if (!before.cameraResult.valid) limitations.add('invalidBeforeMeasurement');
|
||||
if (!after.cameraResult.valid) limitations.add('invalidAfterMeasurement');
|
||||
|
||||
const beforeBpm = before.cameraResult.bpm.bpm;
|
||||
const afterBpm = after.cameraResult.bpm.bpm;
|
||||
const bpm = finite(beforeBpm) && finite(afterBpm)
|
||||
? compareMetric(beforeBpm, afterBpm)
|
||||
: null;
|
||||
if (bpm === null) limitations.add('bpmUnavailable');
|
||||
|
||||
const prvComparable =
|
||||
before.cameraResult.prv.reliable && after.cameraResult.prv.reliable;
|
||||
const beforeRmssd = before.cameraResult.prv.estimatedRmssdMs;
|
||||
const afterRmssd = after.cameraResult.prv.estimatedRmssdMs;
|
||||
const beforeSdnn = before.cameraResult.prv.estimatedSdnnMs;
|
||||
const afterSdnn = after.cameraResult.prv.estimatedSdnnMs;
|
||||
const rmssdMs = prvComparable && finite(beforeRmssd) && finite(afterRmssd)
|
||||
? compareMetric(beforeRmssd, afterRmssd)
|
||||
: null;
|
||||
const sdnnMs = prvComparable && finite(beforeSdnn) && finite(afterSdnn)
|
||||
? compareMetric(beforeSdnn, afterSdnn)
|
||||
: null;
|
||||
if (rmssdMs === null || sdnnMs === null) limitations.add('prvUnavailable');
|
||||
|
||||
const beforeStress = before.potentialStress.index;
|
||||
const afterStress = after.potentialStress.index;
|
||||
const potentialStressIndex = finite(beforeStress) && finite(afterStress)
|
||||
? compareMetric(beforeStress, afterStress)
|
||||
: null;
|
||||
if (potentialStressIndex === null) limitations.add('stressIndexUnavailable');
|
||||
|
||||
const beforeFeeling = before.selfReport?.feeling;
|
||||
const afterFeeling = after.selfReport?.feeling;
|
||||
const feeling = finite(beforeFeeling) && finite(afterFeeling)
|
||||
? compareMetric(beforeFeeling, afterFeeling)
|
||||
: null;
|
||||
|
||||
return {
|
||||
beforeMeasurementId: before.id,
|
||||
afterMeasurementId: after.id,
|
||||
elapsedSeconds: Math.max(
|
||||
0,
|
||||
(Date.parse(after.measuredAt) - Date.parse(before.measuredAt)) / 1_000,
|
||||
),
|
||||
bpm,
|
||||
rmssdMs,
|
||||
sdnnMs,
|
||||
potentialStressIndex,
|
||||
feeling,
|
||||
signalQuality: compareMetric(
|
||||
before.cameraResult.quality.score,
|
||||
after.cameraResult.quality.score,
|
||||
),
|
||||
limitations: [...limitations],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
HAPTIC_REGULATION_CATALOG_VERSION,
|
||||
HAPTIC_REGULATION_PROTOCOLS,
|
||||
getRegulationProtocol,
|
||||
} from './regulationProtocols';
|
||||
|
||||
describe('shared regulation protocol manifest', () => {
|
||||
it('loads the seven unique, versioned protocols', () => {
|
||||
expect(HAPTIC_REGULATION_CATALOG_VERSION).toBe('1.0.0');
|
||||
expect(HAPTIC_REGULATION_PROTOCOLS).toHaveLength(7);
|
||||
expect(new Set(HAPTIC_REGULATION_PROTOCOLS.map(item => item.kind)).size).toBe(7);
|
||||
});
|
||||
|
||||
it('contains finite phases and declared durations', () => {
|
||||
HAPTIC_REGULATION_PROTOCOLS.forEach(protocol => {
|
||||
expect(protocol.durationSeconds).toBeGreaterThan(0);
|
||||
expect(protocol.allowedDurationsSeconds).toContain(protocol.durationSeconds);
|
||||
expect(protocol.phases.length).toBeGreaterThan(0);
|
||||
protocol.phases.forEach(phase => {
|
||||
expect(Number.isFinite(phase.durationSeconds)).toBe(true);
|
||||
expect(phase.durationSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
expect(getRegulationProtocol(protocol.kind)).toBe(protocol);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {activeRegulationPhase} from './regulationPhaseEngine';
|
||||
import {getRegulationProtocol} from './regulationProtocols';
|
||||
|
||||
describe('regulation phase engine', () => {
|
||||
test('crosses every box boundary and loops', () => {
|
||||
const protocol = getRegulationProtocol('box4444');
|
||||
expect(activeRegulationPhase(protocol, 0).phase.label).toBe('Inspirez');
|
||||
expect(activeRegulationPhase(protocol, 4).phase.label).toBe('Gardez l’air');
|
||||
expect(activeRegulationPhase(protocol, 8).phase.label).toBe('Expirez');
|
||||
expect(activeRegulationPhase(protocol, 12).phase.label).toBe('Restez vide');
|
||||
expect(activeRegulationPhase(protocol, 16)).toMatchObject({cycleIndex: 1});
|
||||
});
|
||||
|
||||
test('counts four complete 4-7-8 cycles', () => {
|
||||
const protocol = getRegulationProtocol('breathing478');
|
||||
expect(activeRegulationPhase(protocol, 3.9).phase.kind).toBe('inhale');
|
||||
expect(activeRegulationPhase(protocol, 4).phase.kind).toBe('hold');
|
||||
expect(activeRegulationPhase(protocol, 11).phase.kind).toBe('exhale');
|
||||
expect(activeRegulationPhase(protocol, 75).cycleIndex).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {HapticRegulationProtocol, RegulationPhase} from './regulationProtocols';
|
||||
|
||||
export interface ActiveRegulationPhase {
|
||||
phase: RegulationPhase;
|
||||
remainingSeconds: number;
|
||||
cycleIndex: number;
|
||||
}
|
||||
|
||||
export function activeRegulationPhase(
|
||||
protocol: HapticRegulationProtocol,
|
||||
elapsedSeconds: number,
|
||||
): ActiveRegulationPhase {
|
||||
const cycleDuration = protocol.phases.reduce((sum, phase) => sum + phase.durationSeconds, 0);
|
||||
const safeElapsed = Math.max(0, elapsedSeconds);
|
||||
const cyclePosition = cycleDuration <= 0 ? 0 : safeElapsed % cycleDuration;
|
||||
let boundary = 0;
|
||||
for (const phase of protocol.phases) {
|
||||
boundary += phase.durationSeconds;
|
||||
if (cyclePosition < boundary) {
|
||||
return {
|
||||
phase,
|
||||
remainingSeconds: Math.max(0, Math.ceil(boundary - cyclePosition)),
|
||||
cycleIndex: cycleDuration <= 0 ? 0 : Math.floor(safeElapsed / cycleDuration),
|
||||
};
|
||||
}
|
||||
}
|
||||
const fallback = protocol.phases.at(-1)!;
|
||||
return {phase: fallback, remainingSeconds: 0, cycleIndex: 0};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
formatProtocolDuration,
|
||||
getRegulationProtocol,
|
||||
HAPTIC_REGULATION_PROTOCOLS,
|
||||
recommendRegulationProtocol,
|
||||
} from './regulationProtocols';
|
||||
|
||||
describe('regulation protocols', () => {
|
||||
it('publishes the seven protocols grouped into three families', () => {
|
||||
expect(HAPTIC_REGULATION_PROTOCOLS.map(protocol => protocol.kind)).toEqual([
|
||||
'myCoherence', 'coherence55', 'box4444', 'breathing478',
|
||||
'relax46', 'sosStress', 'microAnchor',
|
||||
]);
|
||||
expect(new Set(HAPTIC_REGULATION_PROTOCOLS.map(protocol => protocol.family))).toEqual(
|
||||
new Set(['breathe', 'ground', 'recenter']),
|
||||
);
|
||||
});
|
||||
|
||||
it('models box and 4-7-8 with exact phases and durations', () => {
|
||||
expect(getRegulationProtocol('box4444')).toMatchObject({
|
||||
durationSeconds: 240,
|
||||
phases: [
|
||||
{kind: 'inhale', durationSeconds: 4},
|
||||
{kind: 'hold', durationSeconds: 4},
|
||||
{kind: 'exhale', durationSeconds: 4},
|
||||
{kind: 'hold', durationSeconds: 4},
|
||||
],
|
||||
});
|
||||
expect(getRegulationProtocol('breathing478')).toMatchObject({
|
||||
durationSeconds: 76,
|
||||
phases: [
|
||||
{kind: 'inhale', durationSeconds: 4},
|
||||
{kind: 'hold', durationSeconds: 7},
|
||||
{kind: 'exhale', durationSeconds: 8},
|
||||
],
|
||||
});
|
||||
expect(getRegulationProtocol('myCoherence')).toMatchObject({
|
||||
mode: 'livePPG',
|
||||
allowedDurationsSeconds: [180, 300],
|
||||
intensity: 'aucune',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats short and long durations', () => {
|
||||
expect(formatProtocolDuration(37)).toBe('37 sec');
|
||||
expect(formatProtocolDuration(180)).toBe('3 min');
|
||||
expect(formatProtocolDuration(62)).toBe('1 min 2 sec');
|
||||
});
|
||||
});
|
||||
|
||||
describe('regulation recommendation', () => {
|
||||
it.each([
|
||||
[null, 'myCoherence'], [20, 'microAnchor'], [45, 'myCoherence'],
|
||||
[70, 'relax46'], [90, 'sosStress'],
|
||||
] as const)('maps activation %s to %s without a diagnosis', (index, expected) => {
|
||||
expect(recommendRegulationProtocol(index)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
export type HapticRegulationProtocolKind =
|
||||
| 'myCoherence'
|
||||
| 'coherence55'
|
||||
| 'box4444'
|
||||
| 'breathing478'
|
||||
| 'relax46'
|
||||
| 'sosStress'
|
||||
| 'microAnchor';
|
||||
|
||||
export type RegulationFamily = 'breathe' | 'ground' | 'recenter';
|
||||
export type RegulationPhaseKind = 'inhale' | 'hold' | 'exhale' | 'anchor';
|
||||
|
||||
export interface RegulationPhase {
|
||||
kind: RegulationPhaseKind;
|
||||
durationSeconds: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProtocolEducation {
|
||||
definition: string;
|
||||
documentedBenefit: string;
|
||||
moduleAction: string;
|
||||
sources: readonly string[];
|
||||
}
|
||||
|
||||
export interface HapticRegulationProtocol {
|
||||
kind: HapticRegulationProtocolKind;
|
||||
family: RegulationFamily;
|
||||
mode: 'haptic' | 'livePPG';
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
durationSeconds: number;
|
||||
allowedDurationsSeconds: readonly number[];
|
||||
phases: readonly RegulationPhase[];
|
||||
intensity: 'aucune' | 'douce' | 'modérée' | 'présente';
|
||||
education: ProtocolEducation;
|
||||
}
|
||||
|
||||
interface RegulationProtocolManifestV1 {
|
||||
schemaVersion: 1;
|
||||
catalogVersion: string;
|
||||
protocols: HapticRegulationProtocol[];
|
||||
}
|
||||
|
||||
// Metro, Jest and the Swift Watch target all consume this same versioned
|
||||
// catalogue. Keeping the runtime assertion here prevents silently accepting a
|
||||
// future incompatible manifest.
|
||||
const protocolManifest = require('../../../shared/regulation-protocols.v1.json') as RegulationProtocolManifestV1;
|
||||
if (protocolManifest.schemaVersion !== 1) {
|
||||
throw new Error('Unsupported regulation protocol manifest');
|
||||
}
|
||||
|
||||
export const HAPTIC_REGULATION_PROTOCOLS: readonly HapticRegulationProtocol[] =
|
||||
protocolManifest.protocols;
|
||||
|
||||
export const HAPTIC_REGULATION_CATALOG_VERSION =
|
||||
protocolManifest.catalogVersion;
|
||||
|
||||
export function getRegulationProtocol(kind: HapticRegulationProtocolKind): HapticRegulationProtocol {
|
||||
return HAPTIC_REGULATION_PROTOCOLS.find(protocol => protocol.kind === kind) ?? HAPTIC_REGULATION_PROTOCOLS[0]!;
|
||||
}
|
||||
|
||||
export function formatProtocolDuration(durationSeconds: number): string {
|
||||
if (durationSeconds < 60) return `${durationSeconds} sec`;
|
||||
const minutes = Math.floor(durationSeconds / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
return seconds === 0 ? `${minutes} min` : `${minutes} min ${seconds} sec`;
|
||||
}
|
||||
|
||||
export function recommendRegulationProtocol(potentialActivationIndex: number | null): HapticRegulationProtocolKind {
|
||||
if (potentialActivationIndex === null || !Number.isFinite(potentialActivationIndex)) return 'myCoherence';
|
||||
if (potentialActivationIndex > 80) return 'sosStress';
|
||||
if (potentialActivationIndex > 60) return 'relax46';
|
||||
if (potentialActivationIndex > 30) return 'myCoherence';
|
||||
return 'microAnchor';
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {storageKeys} from '../ppg/storage/database';
|
||||
|
||||
const mockStore = new Map<string, string>();
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null),
|
||||
setItem: jest.fn(async (key: string, value: string) => {
|
||||
mockStore.set(key, value);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
parseRegulationSession,
|
||||
regulationSessionRepository,
|
||||
} from './regulationSessionRepository';
|
||||
|
||||
describe('regulation session repository', () => {
|
||||
beforeEach(() => mockStore.clear());
|
||||
|
||||
test('persists the full in-progress, awaiting-after and completed lifecycle', async () => {
|
||||
const created = await regulationSessionRepository.create({
|
||||
id: 'session-1',
|
||||
protocolKind: 'coherence55',
|
||||
beforeMeasurementId: 'before-1',
|
||||
startedAt: '2026-07-15T10:00:00.000Z',
|
||||
});
|
||||
expect(created.status).toBe('inProgress');
|
||||
|
||||
const awaiting = await regulationSessionRepository.markAwaitingAfter(
|
||||
created.id,
|
||||
'2026-07-15T10:05:00.000Z',
|
||||
);
|
||||
expect(awaiting.status).toBe('awaitingAfter');
|
||||
|
||||
const completed = await regulationSessionRepository.complete(
|
||||
created.id,
|
||||
'after-1',
|
||||
'2026-07-15T10:07:00.000Z',
|
||||
);
|
||||
expect(completed).toMatchObject({
|
||||
status: 'completed',
|
||||
beforeMeasurementId: 'before-1',
|
||||
afterMeasurementId: 'after-1',
|
||||
});
|
||||
expect(await regulationSessionRepository.getById(created.id)).toEqual(completed);
|
||||
});
|
||||
|
||||
test('serializes concurrent creates without losing either session', async () => {
|
||||
await Promise.all([
|
||||
regulationSessionRepository.create({
|
||||
id: 'session-a',
|
||||
protocolKind: 'relax46',
|
||||
beforeMeasurementId: 'before-a',
|
||||
startedAt: '2026-07-15T11:00:00.000Z',
|
||||
}),
|
||||
regulationSessionRepository.create({
|
||||
id: 'session-b',
|
||||
protocolKind: 'microAnchor',
|
||||
beforeMeasurementId: 'before-b',
|
||||
startedAt: '2026-07-15T11:01:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect((await regulationSessionRepository.getAll()).map(item => item.id)).toEqual([
|
||||
'session-b',
|
||||
'session-a',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects impossible transitions and chronology', async () => {
|
||||
await regulationSessionRepository.create({
|
||||
id: 'session-invalid',
|
||||
protocolKind: 'sosStress',
|
||||
beforeMeasurementId: 'before',
|
||||
startedAt: '2026-07-15T12:00:00.000Z',
|
||||
});
|
||||
await expect(
|
||||
regulationSessionRepository.complete('session-invalid', 'after'),
|
||||
).rejects.toThrow(/await/);
|
||||
await expect(
|
||||
regulationSessionRepository.markAwaitingAfter(
|
||||
'session-invalid',
|
||||
'2026-07-15T11:59:00.000Z',
|
||||
),
|
||||
).rejects.toThrow(/precede/);
|
||||
});
|
||||
|
||||
test('migrates a status-less row and drops malformed storage rows', async () => {
|
||||
const legacy = {
|
||||
schemaVersion: 1,
|
||||
id: 'legacy',
|
||||
protocolKind: 'relax46',
|
||||
beforeMeasurementId: 'before',
|
||||
afterMeasurementId: null,
|
||||
startedAt: '2026-07-15T13:00:00.000Z',
|
||||
exerciseCompletedAt: '2026-07-15T13:03:00.000Z',
|
||||
completedAt: null,
|
||||
updatedAt: '2026-07-15T13:03:00.000Z',
|
||||
};
|
||||
mockStore.set(
|
||||
storageKeys.regulationSessions,
|
||||
JSON.stringify([legacy, {...legacy, id: 'broken', protocolKind: 'unknown'}]),
|
||||
);
|
||||
expect(await regulationSessionRepository.getAll()).toEqual([
|
||||
{...legacy, schemaVersion: 1, status: 'awaitingAfter'},
|
||||
]);
|
||||
expect(parseRegulationSession({...legacy, startedAt: 'invalid'})).toBeNull();
|
||||
});
|
||||
|
||||
test('removes sessions linked to a deleted measurement and can clear everything', async () => {
|
||||
await regulationSessionRepository.create({
|
||||
id: 'linked', protocolKind: 'relax46', beforeMeasurementId: 'measurement-1',
|
||||
startedAt: '2026-07-15T14:00:00.000Z',
|
||||
});
|
||||
await regulationSessionRepository.create({
|
||||
id: 'kept', protocolKind: 'relax46', beforeMeasurementId: 'measurement-2',
|
||||
startedAt: '2026-07-15T14:01:00.000Z',
|
||||
});
|
||||
await regulationSessionRepository.deleteRelatedToMeasurement('measurement-1');
|
||||
expect((await regulationSessionRepository.getAll()).map(item => item.id)).toEqual(['kept']);
|
||||
await regulationSessionRepository.clearAll();
|
||||
expect(await regulationSessionRepository.getAll()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import {readJSON, storageKeys, writeJSON} from '../ppg/storage/database';
|
||||
import {
|
||||
HAPTIC_REGULATION_PROTOCOLS,
|
||||
type HapticRegulationProtocolKind,
|
||||
} from './regulationProtocols';
|
||||
import type {
|
||||
CreateRegulationSessionInput,
|
||||
RegulationSession,
|
||||
RegulationSessionStatus,
|
||||
} from './regulationSessionTypes';
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
const protocolKinds = new Set<HapticRegulationProtocolKind>(
|
||||
HAPTIC_REGULATION_PROTOCOLS.map(protocol => protocol.kind),
|
||||
);
|
||||
const statuses = new Set<RegulationSessionStatus>([
|
||||
'inProgress',
|
||||
'awaitingAfter',
|
||||
'completed',
|
||||
]);
|
||||
|
||||
const isRecord = (value: unknown): value is UnknownRecord =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
const isDateString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
const isNullableDateString = (value: unknown): value is string | null =>
|
||||
value === null || isDateString(value);
|
||||
const isNullableString = (value: unknown): value is string | null =>
|
||||
value === null || isNonEmptyString(value);
|
||||
|
||||
function inferStatus(value: UnknownRecord): RegulationSessionStatus | null {
|
||||
if (typeof value.status === 'string' && statuses.has(value.status as RegulationSessionStatus)) {
|
||||
return value.status as RegulationSessionStatus;
|
||||
}
|
||||
// Early development rows may not contain a status. Infer it from their
|
||||
// nullable milestones so they remain readable without weakening validation.
|
||||
if (isNonEmptyString(value.afterMeasurementId) && isDateString(value.completedAt)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (isDateString(value.exerciseCompletedAt)) return 'awaitingAfter';
|
||||
return value.status === undefined ? 'inProgress' : null;
|
||||
}
|
||||
|
||||
export function parseRegulationSession(value: unknown): RegulationSession | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const status = inferStatus(value);
|
||||
if (
|
||||
(value.schemaVersion !== undefined && value.schemaVersion !== 1) ||
|
||||
!isNonEmptyString(value.id) ||
|
||||
!isNonEmptyString(value.protocolKind) ||
|
||||
!protocolKinds.has(value.protocolKind as HapticRegulationProtocolKind) ||
|
||||
!isNonEmptyString(value.beforeMeasurementId) ||
|
||||
!isNullableString(value.afterMeasurementId) ||
|
||||
status === null ||
|
||||
!isDateString(value.startedAt) ||
|
||||
!isNullableDateString(value.exerciseCompletedAt) ||
|
||||
!isNullableDateString(value.completedAt) ||
|
||||
!isDateString(value.updatedAt)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt = Date.parse(value.startedAt);
|
||||
const exerciseCompletedAt =
|
||||
value.exerciseCompletedAt === null ? null : Date.parse(value.exerciseCompletedAt);
|
||||
const completedAt = value.completedAt === null ? null : Date.parse(value.completedAt);
|
||||
if (
|
||||
(exerciseCompletedAt !== null && exerciseCompletedAt < startedAt) ||
|
||||
(completedAt !== null &&
|
||||
(completedAt < startedAt ||
|
||||
(exerciseCompletedAt !== null && completedAt < exerciseCompletedAt))) ||
|
||||
Date.parse(value.updatedAt) < startedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const afterMeasurementId = value.afterMeasurementId as string | null;
|
||||
const validMilestones =
|
||||
(status === 'inProgress' &&
|
||||
exerciseCompletedAt === null &&
|
||||
completedAt === null &&
|
||||
afterMeasurementId === null) ||
|
||||
(status === 'awaitingAfter' &&
|
||||
exerciseCompletedAt !== null &&
|
||||
completedAt === null &&
|
||||
afterMeasurementId === null) ||
|
||||
(status === 'completed' &&
|
||||
exerciseCompletedAt !== null &&
|
||||
completedAt !== null &&
|
||||
afterMeasurementId !== null &&
|
||||
afterMeasurementId !== value.beforeMeasurementId);
|
||||
if (!validMilestones) return null;
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: value.id,
|
||||
protocolKind: value.protocolKind as HapticRegulationProtocolKind,
|
||||
beforeMeasurementId: value.beforeMeasurementId,
|
||||
afterMeasurementId,
|
||||
status,
|
||||
startedAt: value.startedAt,
|
||||
exerciseCompletedAt: value.exerciseCompletedAt as string | null,
|
||||
completedAt: value.completedAt as string | null,
|
||||
updatedAt: value.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
let mutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function readAllFromStorage(): Promise<RegulationSession[]> {
|
||||
const raw = await readJSON<unknown>(storageKeys.regulationSessions, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const unique = new Map<string, RegulationSession>();
|
||||
raw.forEach(value => {
|
||||
const parsed = parseRegulationSession(value);
|
||||
if (parsed !== null && !unique.has(parsed.id)) unique.set(parsed.id, parsed);
|
||||
});
|
||||
return [...unique.values()].sort(
|
||||
(left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt),
|
||||
);
|
||||
}
|
||||
|
||||
function enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const pending = mutationQueue.then(operation);
|
||||
mutationQueue = pending.then(() => undefined, () => undefined);
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createId(): string {
|
||||
return `regulation-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function validInputDate(value: string, field: string): void {
|
||||
if (!isDateString(value)) throw new Error(`${field} must be a valid date`);
|
||||
}
|
||||
|
||||
export const regulationSessionRepository = {
|
||||
async getAll(): Promise<RegulationSession[]> {
|
||||
await mutationQueue;
|
||||
return readAllFromStorage();
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<RegulationSession | null> {
|
||||
const sessions = await this.getAll();
|
||||
return sessions.find(session => session.id === id) ?? null;
|
||||
},
|
||||
|
||||
async create(input: CreateRegulationSessionInput): Promise<RegulationSession> {
|
||||
return enqueueMutation(async () => {
|
||||
if (!protocolKinds.has(input.protocolKind)) throw new Error('Unknown protocol kind');
|
||||
if (!isNonEmptyString(input.beforeMeasurementId)) {
|
||||
throw new Error('beforeMeasurementId is required');
|
||||
}
|
||||
const id = input.id ?? createId();
|
||||
if (!isNonEmptyString(id)) throw new Error('id is required');
|
||||
const startedAt = input.startedAt ?? new Date().toISOString();
|
||||
validInputDate(startedAt, 'startedAt');
|
||||
const current = await readAllFromStorage();
|
||||
if (current.some(session => session.id === id)) {
|
||||
throw new Error(`Regulation session ${id} already exists`);
|
||||
}
|
||||
const session: RegulationSession = {
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
protocolKind: input.protocolKind,
|
||||
beforeMeasurementId: input.beforeMeasurementId,
|
||||
afterMeasurementId: null,
|
||||
status: 'inProgress',
|
||||
startedAt,
|
||||
exerciseCompletedAt: null,
|
||||
completedAt: null,
|
||||
updatedAt: startedAt,
|
||||
};
|
||||
await writeJSON(storageKeys.regulationSessions, [session, ...current]);
|
||||
return session;
|
||||
});
|
||||
},
|
||||
|
||||
async markAwaitingAfter(
|
||||
id: string,
|
||||
exerciseCompletedAt = new Date().toISOString(),
|
||||
): Promise<RegulationSession> {
|
||||
return enqueueMutation(async () => {
|
||||
validInputDate(exerciseCompletedAt, 'exerciseCompletedAt');
|
||||
const current = await readAllFromStorage();
|
||||
const existing = current.find(session => session.id === id);
|
||||
if (existing === undefined) throw new Error(`Regulation session ${id} not found`);
|
||||
if (existing.status === 'completed') throw new Error('Completed session cannot be reopened');
|
||||
if (Date.parse(exerciseCompletedAt) < Date.parse(existing.startedAt)) {
|
||||
throw new Error('exerciseCompletedAt cannot precede startedAt');
|
||||
}
|
||||
const updated: RegulationSession = {
|
||||
...existing,
|
||||
status: 'awaitingAfter',
|
||||
exerciseCompletedAt,
|
||||
updatedAt: exerciseCompletedAt,
|
||||
};
|
||||
await writeJSON(
|
||||
storageKeys.regulationSessions,
|
||||
current.map(session => (session.id === id ? updated : session)),
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
async complete(
|
||||
id: string,
|
||||
afterMeasurementId: string,
|
||||
completedAt = new Date().toISOString(),
|
||||
): Promise<RegulationSession> {
|
||||
return enqueueMutation(async () => {
|
||||
if (!isNonEmptyString(afterMeasurementId)) throw new Error('afterMeasurementId is required');
|
||||
validInputDate(completedAt, 'completedAt');
|
||||
const current = await readAllFromStorage();
|
||||
const existing = current.find(session => session.id === id);
|
||||
if (existing === undefined) throw new Error(`Regulation session ${id} not found`);
|
||||
if (existing.status !== 'awaitingAfter' || existing.exerciseCompletedAt === null) {
|
||||
throw new Error('Session must await an after measurement before completion');
|
||||
}
|
||||
if (afterMeasurementId === existing.beforeMeasurementId) {
|
||||
throw new Error('Before and after measurements must be different');
|
||||
}
|
||||
if (Date.parse(completedAt) < Date.parse(existing.exerciseCompletedAt)) {
|
||||
throw new Error('completedAt cannot precede exerciseCompletedAt');
|
||||
}
|
||||
const updated: RegulationSession = {
|
||||
...existing,
|
||||
afterMeasurementId,
|
||||
status: 'completed',
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
};
|
||||
await writeJSON(
|
||||
storageKeys.regulationSessions,
|
||||
current.map(session => (session.id === id ? updated : session)),
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
async deleteRelatedToMeasurement(measurementId: string): Promise<void> {
|
||||
return enqueueMutation(async () => {
|
||||
const current = await readAllFromStorage();
|
||||
await writeJSON(
|
||||
storageKeys.regulationSessions,
|
||||
current.filter(
|
||||
session =>
|
||||
session.beforeMeasurementId !== measurementId &&
|
||||
session.afterMeasurementId !== measurementId,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
return enqueueMutation(async () => {
|
||||
await writeJSON(storageKeys.regulationSessions, []);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {HapticRegulationProtocolKind} from './regulationProtocols';
|
||||
|
||||
export type RegulationSessionStatus =
|
||||
| 'inProgress'
|
||||
| 'awaitingAfter'
|
||||
| 'completed';
|
||||
|
||||
export interface RegulationSession {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
protocolKind: HapticRegulationProtocolKind;
|
||||
beforeMeasurementId: string;
|
||||
afterMeasurementId: string | null;
|
||||
status: RegulationSessionStatus;
|
||||
startedAt: string;
|
||||
exerciseCompletedAt: string | null;
|
||||
completedAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateRegulationSessionInput {
|
||||
id?: string;
|
||||
protocolKind: HapticRegulationProtocolKind;
|
||||
beforeMeasurementId: string;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
export type RegulationComparisonLimitation =
|
||||
| 'invalidBeforeMeasurement'
|
||||
| 'invalidAfterMeasurement'
|
||||
| 'bpmUnavailable'
|
||||
| 'prvUnavailable'
|
||||
| 'stressIndexUnavailable';
|
||||
|
||||
export interface MetricComparison {
|
||||
before: number;
|
||||
after: number;
|
||||
delta: number;
|
||||
percentChange: number | null;
|
||||
}
|
||||
|
||||
export interface RegulationComparison {
|
||||
beforeMeasurementId: string;
|
||||
afterMeasurementId: string;
|
||||
elapsedSeconds: number;
|
||||
bpm: MetricComparison | null;
|
||||
rmssdMs: MetricComparison | null;
|
||||
sdnnMs: MetricComparison | null;
|
||||
potentialStressIndex: MetricComparison | null;
|
||||
feeling: MetricComparison | null;
|
||||
signalQuality: MetricComparison;
|
||||
limitations: readonly RegulationComparisonLimitation[];
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {AppState, Pressable, StyleSheet, Text, View} from 'react-native';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {haptikaPPG, type PPGFrameUpdate} from 'react-native-haptika-ppg';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {colors, radii, spacing} from '../../theme';
|
||||
import {PPGCameraView} from '../ppg/camera/PPGCameraView';
|
||||
import type {PPGCameraError} from '../ppg/camera/cameraTypes';
|
||||
import {PrimaryButton} from '../ppg/components/PrimaryButton';
|
||||
import {
|
||||
calculateCoherenceScore,
|
||||
coherenceScoreLabel,
|
||||
smoothCoherenceScore,
|
||||
} from './coherenceEngine';
|
||||
import {coherenceSessionRepository} from './coherenceSessionRepository';
|
||||
import type {
|
||||
CoherenceBeat,
|
||||
CoherenceScoreSnapshot,
|
||||
CoherenceSessionDurationSeconds,
|
||||
StoredCoherenceSession,
|
||||
} from './coherenceTypes';
|
||||
|
||||
export interface MyCoherenceSummary {
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
elapsedSeconds: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
interface MyCoherenceScreenProps {
|
||||
cameraPosition: 'front' | 'back';
|
||||
initialDurationSeconds?: CoherenceSessionDurationSeconds;
|
||||
beforeMeasurementId?: string | null;
|
||||
regulationSessionId?: string | null;
|
||||
onCompleted: (summary: MyCoherenceSummary, session: StoredCoherenceSession) => void;
|
||||
onCancelled: (summary: MyCoherenceSummary, session: StoredCoherenceSession | null) => void;
|
||||
}
|
||||
|
||||
function waveformPath(values: readonly number[], width: number, height: number): string {
|
||||
if (values.length < 2) return '';
|
||||
const finiteValues = values.filter(Number.isFinite);
|
||||
const minimum = Math.min(...finiteValues);
|
||||
const maximum = Math.max(...finiteValues);
|
||||
const range = Math.max(maximum - minimum, 0.00001);
|
||||
return values.map((value, index) => {
|
||||
const x = (index / (values.length - 1)) * width;
|
||||
const y = height - ((value - minimum) / range) * height;
|
||||
return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function MyCoherenceScreen({
|
||||
cameraPosition,
|
||||
initialDurationSeconds = 180,
|
||||
beforeMeasurementId = null,
|
||||
regulationSessionId = null,
|
||||
onCompleted,
|
||||
onCancelled,
|
||||
}: MyCoherenceScreenProps): React.JSX.Element {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [durationSeconds, setDurationSeconds] = useState<CoherenceSessionDurationSeconds>(initialDurationSeconds);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [latestFrame, setLatestFrame] = useState<PPGFrameUpdate | null>(null);
|
||||
const [scoreSnapshot, setScoreSnapshot] = useState<CoherenceScoreSnapshot | null>(null);
|
||||
const [waveform, setWaveform] = useState<number[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const startFrameTimeRef = useRef<number | null>(null);
|
||||
const startedAtRef = useRef<string>('');
|
||||
const beatsRef = useRef<CoherenceBeat[]>([]);
|
||||
const timelineRef = useRef<CoherenceScoreSnapshot[]>([]);
|
||||
const lastScoreAtRef = useRef(-Infinity);
|
||||
const smoothedScoreRef = useRef<number | null>(null);
|
||||
const terminalRef = useRef(false);
|
||||
|
||||
const stopNative = useCallback(() => {
|
||||
try { haptikaPPG.cancelMeasurement(); } catch { /* already stopped */ }
|
||||
setRunning(false);
|
||||
}, []);
|
||||
|
||||
const persistAndLeave = useCallback(async (completed: boolean): Promise<void> => {
|
||||
if (terminalRef.current) return;
|
||||
terminalRef.current = true;
|
||||
stopNative();
|
||||
const completedAt = new Date().toISOString();
|
||||
const summary: MyCoherenceSummary = {
|
||||
startedAt: startedAtRef.current || completedAt,
|
||||
completedAt,
|
||||
elapsedSeconds,
|
||||
completed,
|
||||
};
|
||||
let stored: StoredCoherenceSession | null = null;
|
||||
try {
|
||||
stored = await coherenceSessionRepository.save({
|
||||
regulationSessionId,
|
||||
beforeMeasurementId,
|
||||
cameraPosition,
|
||||
plannedDurationSeconds: durationSeconds,
|
||||
actualDurationSeconds: elapsedSeconds,
|
||||
startedAt: summary.startedAt,
|
||||
completedAt,
|
||||
completed,
|
||||
timeline: timelineRef.current,
|
||||
});
|
||||
} catch {
|
||||
// A local persistence failure must not keep the camera active.
|
||||
}
|
||||
if (completed && stored !== null) onCompleted(summary, stored);
|
||||
else onCancelled(summary, stored);
|
||||
}, [beforeMeasurementId, cameraPosition, durationSeconds, elapsedSeconds, onCancelled, onCompleted, regulationSessionId, stopNative]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', next => {
|
||||
if (running && next !== 'active') persistAndLeave(false);
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [persistAndLeave, running]);
|
||||
|
||||
useEffect(() => stopNative, [stopNative]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
terminalRef.current = false;
|
||||
startFrameTimeRef.current = null;
|
||||
startedAtRef.current = new Date().toISOString();
|
||||
beatsRef.current = [];
|
||||
timelineRef.current = [];
|
||||
lastScoreAtRef.current = -Infinity;
|
||||
smoothedScoreRef.current = null;
|
||||
setElapsedSeconds(0);
|
||||
setScoreSnapshot(null);
|
||||
setWaveform([]);
|
||||
setError(null);
|
||||
try {
|
||||
haptikaPPG.startMeasurement({
|
||||
sessionPurpose: 'coherence',
|
||||
targetSampleRate: 30,
|
||||
minimumFrequencyHz: 0.7,
|
||||
maximumFrequencyHz: 3.5,
|
||||
stabilizationSeconds: 0,
|
||||
measurementSeconds: durationSeconds,
|
||||
emitEveryFrames: 3,
|
||||
minimumContactConfidence: 0.48,
|
||||
});
|
||||
setRunning(true);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : 'Impossible de démarrer la caméra.');
|
||||
}
|
||||
}, [durationSeconds]);
|
||||
|
||||
const onFrameUpdate = useCallback((update: PPGFrameUpdate): void => {
|
||||
if (!running || terminalRef.current) return;
|
||||
setLatestFrame(update);
|
||||
const sessionStart = startFrameTimeRef.current ?? update.timestampSeconds;
|
||||
startFrameTimeRef.current = sessionStart;
|
||||
const elapsed = Math.max(0, update.timestampSeconds - sessionStart);
|
||||
setElapsedSeconds(elapsed);
|
||||
setWaveform(current => [...current.slice(-89), update.waveformValue]);
|
||||
update.acceptedBeats.forEach(beat => {
|
||||
if (beat.confidence >= 0.35 && Number.isFinite(beat.intervalMs)) {
|
||||
beatsRef.current.push({
|
||||
timestampSeconds: beat.timestampSeconds,
|
||||
intervalMs: beat.intervalMs,
|
||||
confidence: beat.confidence,
|
||||
});
|
||||
}
|
||||
});
|
||||
const quality = Math.min(
|
||||
1,
|
||||
Math.max(0, update.liveWindowQuality ?? update.contactConfidence * (1 - update.motionArtifactScore)),
|
||||
);
|
||||
if (elapsed - lastScoreAtRef.current >= 2) {
|
||||
lastScoreAtRef.current = elapsed;
|
||||
const calculated = calculateCoherenceScore({
|
||||
beats: beatsRef.current,
|
||||
nowSeconds: update.timestampSeconds,
|
||||
sessionStartedAtSeconds: sessionStart,
|
||||
quality,
|
||||
});
|
||||
const smoothed = smoothCoherenceScore(smoothedScoreRef.current, calculated.score);
|
||||
smoothedScoreRef.current = smoothed;
|
||||
const point = {...calculated, score: smoothed};
|
||||
timelineRef.current.push(point);
|
||||
setScoreSnapshot(point);
|
||||
}
|
||||
if (update.thermalState === 'serious' || update.thermalState === 'critical') {
|
||||
setError('L’iPhone a trop chauffé. La séance est arrêtée pour votre confort.');
|
||||
persistAndLeave(false);
|
||||
return;
|
||||
}
|
||||
if (elapsed >= durationSeconds) persistAndLeave(true);
|
||||
}, [durationSeconds, persistAndLeave, running]);
|
||||
|
||||
const handleCameraError = useCallback((cameraError: PPGCameraError): void => {
|
||||
setError(cameraError.message);
|
||||
persistAndLeave(false);
|
||||
}, [persistAndLeave]);
|
||||
|
||||
const breathingPosition = elapsedSeconds % 10;
|
||||
const inhaling = breathingPosition < 5;
|
||||
const phaseProgress = inhaling ? breathingPosition / 5 : (breathingPosition - 5) / 5;
|
||||
const orbScale = inhaling ? 0.72 + 0.28 * phaseProgress : 1 - 0.28 * phaseProgress;
|
||||
const progress = Math.min(1, elapsedSeconds / durationSeconds);
|
||||
const displayedScore = scoreSnapshot?.score ?? null;
|
||||
const path = useMemo(() => waveformPath(waveform, 320, 74), [waveform]);
|
||||
|
||||
if (!running) {
|
||||
return (
|
||||
<View style={[styles.root, {paddingTop: insets.top + spacing.lg}]}>
|
||||
<Text style={styles.eyebrow}>BIOFEEDBACK RESPIRATOIRE</Text>
|
||||
<Text style={styles.title}>Ma Cohérence</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
La caméra estime votre pouls pendant qu’un rythme 5–5 guide votre respiration. Aucune image n’est enregistrée.
|
||||
</Text>
|
||||
<View style={styles.durationCard}>
|
||||
<Text style={styles.cardLabel}>DURÉE DE LA SÉANCE</Text>
|
||||
<View style={styles.segmented}>
|
||||
{([180, 300] as const).map(value => (
|
||||
<Pressable key={value} onPress={() => setDurationSeconds(value)} style={[styles.segment, durationSeconds === value && styles.segmentSelected]}>
|
||||
<Text style={[styles.segmentText, durationSeconds === value && styles.segmentTextSelected]}>{value / 60} min</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.notice}><Text style={styles.noticeText}>Pendant la séance, Haptika ne produit aucune vibration afin de ne pas perturber le signal caméra. Le score est expérimental et n’est pas une mesure médicale.</Text></View>
|
||||
{error !== null ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<PrimaryButton label="Commencer" onPress={start} />
|
||||
<PrimaryButton label="Retour" variant="ghost" onPress={() => onCancelled({startedAt: '', completedAt: new Date().toISOString(), elapsedSeconds: 0, completed: false}, null)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<PPGCameraView
|
||||
isActive
|
||||
cameraPosition={cameraPosition}
|
||||
torchEnabled={cameraPosition === 'back'}
|
||||
lockOptics={latestFrame?.contactState === 'valid'}
|
||||
onCameraStarted={() => undefined}
|
||||
onFrameUpdate={onFrameUpdate}
|
||||
onInterrupted={() => persistAndLeave(false)}
|
||||
onError={handleCameraError}
|
||||
/>
|
||||
<View style={styles.canvas} />
|
||||
<View style={[styles.sessionContent, {paddingTop: insets.top + spacing.lg, paddingBottom: insets.bottom + spacing.md}]}>
|
||||
<View style={styles.sessionHeader}>
|
||||
<View><Text style={styles.eyebrow}>MA COHÉRENCE · DIRECT</Text><Text style={styles.time}>{Math.max(0, Math.ceil(durationSeconds - elapsedSeconds))} s</Text></View>
|
||||
<View style={styles.qualityPill}><View style={[styles.qualityDot, {backgroundColor: (scoreSnapshot?.quality ?? 0) >= 0.65 ? colors.good : colors.medium}]} /><Text style={styles.qualityText}>Signal {Math.round((scoreSnapshot?.quality ?? 0) * 100)} %</Text></View>
|
||||
</View>
|
||||
|
||||
<View style={styles.orbStage}>
|
||||
<View style={[styles.orbOuter, {transform: [{scale: orbScale}], opacity: 0.55 + (displayedScore ?? 0) / 220}]}>
|
||||
<View style={styles.orbInner} />
|
||||
</View>
|
||||
<View style={styles.orbCopy}>
|
||||
<Text style={styles.phase}>{inhaling ? 'Inspirez' : 'Expirez'}</Text>
|
||||
<Text style={styles.phaseCount}>{Math.ceil(5 - (breathingPosition % 5))}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.scoreRow}>
|
||||
<View><Text style={styles.cardLabel}>SYNCHRONISATION</Text><Text style={styles.score}>{displayedScore === null ? '—' : displayedScore}</Text></View>
|
||||
<View style={styles.scoreCopy}><Text style={styles.scoreLabel}>{coherenceScoreLabel(displayedScore)}</Text><Text style={styles.bpm}>{scoreSnapshot?.bpm ?? '—'} BPM</Text></View>
|
||||
</View>
|
||||
|
||||
<View style={styles.waveCard}>
|
||||
<Svg width="100%" height={74} viewBox="0 0 320 74"><Path d={path} stroke={colors.accent} strokeWidth={2.2} fill="none" /></Svg>
|
||||
<Text style={styles.waveCaption}>Variation optique indicative · pas un tracé ECG</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressTrack}><View style={[styles.progressFill, {width: `${progress * 100}%`}]} /></View>
|
||||
<Text style={styles.sessionHint}>{scoreSnapshot?.status === 'poorSignal' ? 'Signal momentanément insuffisant — la séance continue.' : scoreSnapshot?.status === 'calibrating' ? 'Calibration en cours · premier score après environ 60 secondes.' : 'Synchronisation avec le rythme respiratoire guidé.'}</Text>
|
||||
<PrimaryButton label="Arrêter" variant="secondary" onPress={() => persistAndLeave(false)} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {flex: 1, backgroundColor: colors.background, paddingHorizontal: spacing.lg, gap: spacing.lg},
|
||||
canvas: {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: colors.background},
|
||||
eyebrow: {color: colors.accent, fontSize: 10, fontWeight: '900', letterSpacing: 1.8},
|
||||
title: {color: colors.text, fontSize: 42, lineHeight: 48, fontWeight: '900'},
|
||||
subtitle: {color: colors.muted, fontSize: 16, lineHeight: 24},
|
||||
durationCard: {padding: spacing.md, gap: spacing.md, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface},
|
||||
cardLabel: {color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.3},
|
||||
segmented: {flexDirection: 'row', padding: 4, borderRadius: radii.md, backgroundColor: colors.background},
|
||||
segment: {flex: 1, minHeight: 46, alignItems: 'center', justifyContent: 'center', borderRadius: radii.sm},
|
||||
segmentSelected: {backgroundColor: colors.accent},
|
||||
segmentText: {color: colors.muted, fontWeight: '800'},
|
||||
segmentTextSelected: {color: colors.background},
|
||||
notice: {padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surfaceElevated},
|
||||
noticeText: {color: colors.muted, fontSize: 13, lineHeight: 20},
|
||||
error: {color: colors.poor, fontSize: 14, lineHeight: 20},
|
||||
sessionContent: {flex: 1, justifyContent: 'space-between', gap: spacing.sm},
|
||||
sessionHeader: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start'},
|
||||
time: {color: colors.text, fontSize: 17, fontWeight: '800', marginTop: 4, fontVariant: ['tabular-nums']},
|
||||
qualityPill: {flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.sm, minHeight: 34, borderRadius: radii.pill, borderWidth: 1, borderColor: colors.border},
|
||||
qualityDot: {width: 7, height: 7, borderRadius: 4},
|
||||
qualityText: {color: colors.muted, fontSize: 12, fontVariant: ['tabular-nums']},
|
||||
orbStage: {height: 260, alignItems: 'center', justifyContent: 'center'},
|
||||
orbOuter: {width: 230, height: 230, borderRadius: 115, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.accent, backgroundColor: '#072443'},
|
||||
orbInner: {width: 130, height: 130, borderRadius: 65, backgroundColor: colors.accentSoft, borderWidth: 2, borderColor: '#B8EEFF'},
|
||||
orbCopy: {position: 'absolute', alignItems: 'center'},
|
||||
phase: {color: colors.text, fontSize: 23, fontWeight: '800'},
|
||||
phaseCount: {color: colors.accent, fontSize: 36, fontWeight: '300', fontVariant: ['tabular-nums']},
|
||||
scoreRow: {flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: spacing.sm},
|
||||
score: {color: colors.text, fontSize: 62, lineHeight: 66, fontWeight: '900', fontVariant: ['tabular-nums']},
|
||||
scoreCopy: {alignItems: 'flex-end', paddingBottom: 8, maxWidth: '62%'},
|
||||
scoreLabel: {color: colors.accent, fontSize: 15, lineHeight: 20, fontWeight: '800', textAlign: 'right'},
|
||||
bpm: {color: colors.muted, marginTop: 4, fontSize: 13, fontVariant: ['tabular-nums']},
|
||||
waveCard: {height: 112, paddingHorizontal: spacing.sm, paddingTop: spacing.sm, borderRadius: radii.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface},
|
||||
waveCaption: {color: colors.muted, fontSize: 10, textAlign: 'center'},
|
||||
progressTrack: {height: 5, borderRadius: 3, backgroundColor: colors.surfaceElevated, overflow: 'hidden'},
|
||||
progressFill: {height: 5, borderRadius: 3, backgroundColor: colors.accent},
|
||||
sessionHint: {color: colors.muted, fontSize: 12, lineHeight: 17, textAlign: 'center'},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
calculateCoherenceScore,
|
||||
coherenceScoreLabel,
|
||||
smoothCoherenceScore,
|
||||
} from './coherenceEngine';
|
||||
import type {CoherenceBeat} from './coherenceTypes';
|
||||
|
||||
function resonantBeats(duration = 70, frequency = 0.1): CoherenceBeat[] {
|
||||
const beats: CoherenceBeat[] = [];
|
||||
let timestamp = 0;
|
||||
while (timestamp < duration) {
|
||||
const intervalMs = 800 + 105 * Math.sin(2 * Math.PI * frequency * timestamp);
|
||||
timestamp += intervalMs / 1_000;
|
||||
beats.push({timestampSeconds: timestamp, intervalMs, confidence: 0.95});
|
||||
}
|
||||
return beats;
|
||||
}
|
||||
|
||||
describe('experimental coherence engine', () => {
|
||||
test('scores a clean 0.1 Hz modulation and keeps every output finite', () => {
|
||||
const result = calculateCoherenceScore({
|
||||
beats: resonantBeats(), nowSeconds: 70, sessionStartedAtSeconds: 0, quality: 0.9,
|
||||
});
|
||||
expect(result.status).toBe('scored');
|
||||
expect(result.score).not.toBeNull();
|
||||
expect(result.score!).toBeGreaterThan(55);
|
||||
expect(result.score!).toBeLessThanOrEqual(100);
|
||||
expect(Object.values(result.components!)).toEqual(
|
||||
expect.arrayContaining([expect.any(Number)]),
|
||||
);
|
||||
Object.values(result.components!).forEach(value => expect(Number.isFinite(value)).toBe(true));
|
||||
});
|
||||
|
||||
test('does not publish before calibration or during poor signal', () => {
|
||||
expect(calculateCoherenceScore({
|
||||
beats: resonantBeats(40), nowSeconds: 40, sessionStartedAtSeconds: 0, quality: 0.9,
|
||||
})).toMatchObject({score: null, status: 'calibrating'});
|
||||
expect(calculateCoherenceScore({
|
||||
beats: resonantBeats(), nowSeconds: 70, sessionStartedAtSeconds: 0, quality: 0.64,
|
||||
})).toMatchObject({score: null, status: 'poorSignal'});
|
||||
});
|
||||
|
||||
test('rejects invalid and low-confidence intervals without producing NaN', () => {
|
||||
const beats = resonantBeats();
|
||||
beats.push(
|
||||
{timestampSeconds: 68, intervalMs: Number.NaN, confidence: 1},
|
||||
{timestampSeconds: 69, intervalMs: 2_500, confidence: 1},
|
||||
{timestampSeconds: 69.5, intervalMs: 800, confidence: 0.1},
|
||||
);
|
||||
const result = calculateCoherenceScore({beats, nowSeconds: 70, sessionStartedAtSeconds: 0, quality: 0.9});
|
||||
expect(result.score === null || Number.isFinite(result.score)).toBe(true);
|
||||
expect(Number.isFinite(result.coverage)).toBe(true);
|
||||
});
|
||||
|
||||
test('smooths with the versioned EMA and labels exact boundaries', () => {
|
||||
expect(smoothCoherenceScore(null, 80)).toBe(80);
|
||||
expect(smoothCoherenceScore(80, 40)).toBe(70);
|
||||
expect(coherenceScoreLabel(39)).toContain('construction');
|
||||
expect(coherenceScoreLabel(40)).toContain('progression');
|
||||
expect(coherenceScoreLabel(69)).toContain('progression');
|
||||
expect(coherenceScoreLabel(70)).toContain('stable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
CoherenceBeat,
|
||||
CoherenceScoreSnapshot,
|
||||
} from './coherenceTypes';
|
||||
|
||||
const WINDOW_SECONDS = 60;
|
||||
const SAMPLE_RATE_HZ = 4;
|
||||
const MINIMUM_INTERVALS = 35;
|
||||
|
||||
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
function median(values: readonly number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1]! + sorted[middle]!) / 2
|
||||
: sorted[middle]!;
|
||||
}
|
||||
|
||||
function filterIntervals(beats: readonly CoherenceBeat[]): CoherenceBeat[] {
|
||||
return beats.filter((beat, index, source) => {
|
||||
if (
|
||||
!Number.isFinite(beat.timestampSeconds) ||
|
||||
!Number.isFinite(beat.intervalMs) ||
|
||||
beat.intervalMs < 300 ||
|
||||
beat.intervalMs > 2_000 ||
|
||||
!Number.isFinite(beat.confidence) ||
|
||||
beat.confidence < 0.35
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const neighborhood = source
|
||||
.slice(Math.max(0, index - 3), Math.min(source.length, index + 4))
|
||||
.map(candidate => candidate.intervalMs)
|
||||
.filter(value => value >= 300 && value <= 2_000);
|
||||
const center = median(neighborhood);
|
||||
const deviations = neighborhood.map(value => Math.abs(value - center));
|
||||
const mad = median(deviations);
|
||||
const robustLimit = Math.max(120, 3 * 1.4826 * mad);
|
||||
return Math.abs(beat.intervalMs - center) <= robustLimit;
|
||||
});
|
||||
}
|
||||
|
||||
function resample(
|
||||
beats: readonly CoherenceBeat[],
|
||||
start: number,
|
||||
end: number,
|
||||
): {times: number[]; values: number[]} {
|
||||
const times: number[] = [];
|
||||
const values: number[] = [];
|
||||
let cursor = 0;
|
||||
const step = 1 / SAMPLE_RATE_HZ;
|
||||
for (let time = start; time <= end + 1e-6; time += step) {
|
||||
while (
|
||||
cursor + 1 < beats.length &&
|
||||
beats[cursor + 1]!.timestampSeconds < time
|
||||
) {
|
||||
cursor += 1;
|
||||
}
|
||||
const left = beats[cursor];
|
||||
const right = beats[Math.min(cursor + 1, beats.length - 1)];
|
||||
if (left === undefined || right === undefined) continue;
|
||||
const gap = right.timestampSeconds - left.timestampSeconds;
|
||||
if (gap <= 0 || gap > 2) continue;
|
||||
const ratio = clamp01((time - left.timestampSeconds) / gap);
|
||||
times.push(time);
|
||||
values.push(left.intervalMs + (right.intervalMs - left.intervalMs) * ratio);
|
||||
}
|
||||
return {times, values};
|
||||
}
|
||||
|
||||
function detrend(values: readonly number[]): number[] {
|
||||
if (values.length < 2) return [...values];
|
||||
const n = values.length;
|
||||
const meanX = (n - 1) / 2;
|
||||
const meanY = values.reduce((sum, value) => sum + value, 0) / n;
|
||||
let numerator = 0;
|
||||
let denominator = 0;
|
||||
values.forEach((value, index) => {
|
||||
numerator += (index - meanX) * (value - meanY);
|
||||
denominator += (index - meanX) ** 2;
|
||||
});
|
||||
const slope = denominator === 0 ? 0 : numerator / denominator;
|
||||
return values.map((value, index) => value - (meanY + slope * (index - meanX)));
|
||||
}
|
||||
|
||||
function spectralPower(values: readonly number[], frequencyHz: number): number {
|
||||
if (values.length < 2) return 0;
|
||||
let real = 0;
|
||||
let imaginary = 0;
|
||||
values.forEach((value, index) => {
|
||||
const hann = 0.5 * (1 - Math.cos((2 * Math.PI * index) / (values.length - 1)));
|
||||
const angle = (2 * Math.PI * frequencyHz * index) / SAMPLE_RATE_HZ;
|
||||
real += value * hann * Math.cos(angle);
|
||||
imaginary -= value * hann * Math.sin(angle);
|
||||
});
|
||||
return real * real + imaginary * imaginary;
|
||||
}
|
||||
|
||||
function correlation(left: readonly number[], right: readonly number[]): number {
|
||||
const count = Math.min(left.length, right.length);
|
||||
if (count < 8) return 0;
|
||||
const leftMean = left.slice(0, count).reduce((sum, value) => sum + value, 0) / count;
|
||||
const rightMean = right.slice(0, count).reduce((sum, value) => sum + value, 0) / count;
|
||||
let numerator = 0;
|
||||
let leftPower = 0;
|
||||
let rightPower = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const l = left[index]! - leftMean;
|
||||
const r = right[index]! - rightMean;
|
||||
numerator += l * r;
|
||||
leftPower += l * l;
|
||||
rightPower += r * r;
|
||||
}
|
||||
const denominator = Math.sqrt(leftPower * rightPower);
|
||||
return denominator === 0 ? 0 : numerator / denominator;
|
||||
}
|
||||
|
||||
function coefficientOfVariation(values: readonly number[]): number {
|
||||
if (values.length < 2) return 1;
|
||||
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
if (mean <= 0) return 1;
|
||||
const variance =
|
||||
values.reduce((sum, value) => sum + (value - mean) ** 2, 0) /
|
||||
values.length;
|
||||
return Math.sqrt(variance) / mean;
|
||||
}
|
||||
|
||||
export interface CalculateCoherenceScoreInput {
|
||||
beats: readonly CoherenceBeat[];
|
||||
nowSeconds: number;
|
||||
sessionStartedAtSeconds: number;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
/** Experimental PRV-biofeedback score. It never represents a medical measure. */
|
||||
export function calculateCoherenceScore({
|
||||
beats,
|
||||
nowSeconds,
|
||||
sessionStartedAtSeconds,
|
||||
quality,
|
||||
}: CalculateCoherenceScoreInput): CoherenceScoreSnapshot {
|
||||
const windowStart = Math.max(sessionStartedAtSeconds, nowSeconds - WINDOW_SECONDS);
|
||||
const filtered = filterIntervals(
|
||||
beats.filter(beat => beat.timestampSeconds >= windowStart && beat.timestampSeconds <= nowSeconds),
|
||||
);
|
||||
const elapsed = nowSeconds - sessionStartedAtSeconds;
|
||||
const expectedWindow = Math.min(WINDOW_SECONDS, Math.max(0, elapsed));
|
||||
const first = filtered[0]?.timestampSeconds ?? nowSeconds;
|
||||
const last = filtered.at(-1)?.timestampSeconds ?? first;
|
||||
const coverage = expectedWindow <= 0 ? 0 : clamp01((last - first) / expectedWindow);
|
||||
const recentIpi = filtered.slice(-8).map(beat => beat.intervalMs);
|
||||
const bpm = recentIpi.length === 0 ? null : 60_000 / median(recentIpi);
|
||||
|
||||
const unavailable = (status: 'calibrating' | 'poorSignal'): CoherenceScoreSnapshot => ({
|
||||
timestampSeconds: nowSeconds - sessionStartedAtSeconds,
|
||||
score: null,
|
||||
quality: clamp01(quality),
|
||||
bpm: bpm === null ? null : Math.round(bpm),
|
||||
status,
|
||||
components: null,
|
||||
validIntervalCount: filtered.length,
|
||||
coverage,
|
||||
});
|
||||
if (quality < 0.65) return unavailable('poorSignal');
|
||||
if (elapsed < WINDOW_SECONDS || filtered.length < MINIMUM_INTERVALS || coverage < 0.75) {
|
||||
return unavailable('calibrating');
|
||||
}
|
||||
|
||||
const sampled = resample(filtered, nowSeconds - WINDOW_SECONDS, nowSeconds);
|
||||
if (sampled.values.length < WINDOW_SECONDS * SAMPLE_RATE_HZ * 0.75) {
|
||||
return unavailable('poorSignal');
|
||||
}
|
||||
const signal = detrend(sampled.values);
|
||||
let totalPower = 0;
|
||||
let resonancePower = 0;
|
||||
for (let frequency = 0.04; frequency <= 0.4 + 1e-8; frequency += 0.005) {
|
||||
const power = spectralPower(signal, frequency);
|
||||
totalPower += power;
|
||||
if (frequency >= 0.075 && frequency <= 0.125) resonancePower += power;
|
||||
}
|
||||
const spectralRatio = totalPower <= 0 ? 0 : resonancePower / totalPower;
|
||||
const spectral = clamp01((spectralRatio - 0.2) / 0.6);
|
||||
|
||||
let bestCorrelation = 0;
|
||||
for (let lag = 0; lag <= 4 * SAMPLE_RATE_HZ; lag += 1) {
|
||||
const guide = sampled.times.map(
|
||||
time => Math.sin(2 * Math.PI * 0.1 * (time - sessionStartedAtSeconds - lag / SAMPLE_RATE_HZ)),
|
||||
);
|
||||
bestCorrelation = Math.max(bestCorrelation, correlation(signal, guide));
|
||||
}
|
||||
const phase = clamp01((bestCorrelation - 0.2) / 0.7);
|
||||
|
||||
const amplitudes: number[] = [];
|
||||
const samplesPerCycle = 10 * SAMPLE_RATE_HZ;
|
||||
for (let offset = 0; offset + samplesPerCycle <= signal.length; offset += samplesPerCycle) {
|
||||
const cycle = signal.slice(offset, offset + samplesPerCycle);
|
||||
amplitudes.push(Math.max(...cycle) - Math.min(...cycle));
|
||||
}
|
||||
const regularity = clamp01(1 - coefficientOfVariation(amplitudes) / 0.35);
|
||||
const score = Math.round(100 * (0.5 * spectral + 0.35 * phase + 0.15 * regularity));
|
||||
return {
|
||||
timestampSeconds: nowSeconds - sessionStartedAtSeconds,
|
||||
score,
|
||||
quality: clamp01(quality),
|
||||
bpm: bpm === null ? null : Math.round(bpm),
|
||||
status: 'scored',
|
||||
components: {spectral, phase, regularity},
|
||||
validIntervalCount: filtered.length,
|
||||
coverage,
|
||||
};
|
||||
}
|
||||
|
||||
export function smoothCoherenceScore(
|
||||
previous: number | null,
|
||||
next: number | null,
|
||||
): number | null {
|
||||
if (next === null) return null;
|
||||
if (previous === null) return next;
|
||||
return Math.round(previous * 0.75 + next * 0.25);
|
||||
}
|
||||
|
||||
export function coherenceScoreLabel(score: number | null): string {
|
||||
if (score === null) return 'Synchronisation en construction';
|
||||
if (score < 40) return 'Synchronisation en construction';
|
||||
if (score < 70) return 'Synchronisation en progression';
|
||||
return 'Synchronisation stable';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {getItem: jest.fn(async () => null), setItem: jest.fn(async () => undefined)},
|
||||
}));
|
||||
|
||||
import {__coherenceRepositoryTesting} from './coherenceSessionRepository';
|
||||
import type {CoherenceScoreSnapshot} from './coherenceTypes';
|
||||
|
||||
const point = (timestampSeconds: number, score: number | null): CoherenceScoreSnapshot => ({
|
||||
timestampSeconds,
|
||||
score,
|
||||
quality: 0.8,
|
||||
bpm: 72 + timestampSeconds / 10,
|
||||
status: score === null ? 'calibrating' : 'scored',
|
||||
components: score === null ? null : {spectral: 0.5, phase: 0.6, regularity: 0.7},
|
||||
validIntervalCount: 50,
|
||||
coverage: 0.9,
|
||||
});
|
||||
|
||||
describe('coherence session repository summary', () => {
|
||||
test('compacts to one point every two seconds and calculates summaries', () => {
|
||||
const record = __coherenceRepositoryTesting.createRecord({
|
||||
cameraPosition: 'front', plannedDurationSeconds: 180, actualDurationSeconds: 65,
|
||||
startedAt: '2026-07-20T10:00:00.000Z', completedAt: '2026-07-20T10:01:05.000Z',
|
||||
completed: true,
|
||||
timeline: [point(0, null), point(1, null), point(60, 50), point(61, 60), point(63, 80), point(65, 70), point(66, 90)],
|
||||
});
|
||||
expect(record.timeline.map(item => item.timestampSeconds)).toEqual([0, 60, 62, 64, 66]);
|
||||
expect(record.meanScore).toBe(75);
|
||||
expect(record.bestScore).toBe(90);
|
||||
expect(record.finalScore).toBe(75);
|
||||
expect(record.validScoreSeconds).toBe(8);
|
||||
expect(record.cameraPosition).toBe('front');
|
||||
expect(JSON.stringify(record)).not.toMatch(/image|frame/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {readJSON, storageKeys, writeJSON} from '../ppg/storage/database';
|
||||
import {
|
||||
COHERENCE_ALGORITHM_VERSION,
|
||||
type CoherenceScoreSnapshot,
|
||||
type StoredCoherenceSession,
|
||||
} from './coherenceTypes';
|
||||
|
||||
export interface SaveCoherenceSessionInput {
|
||||
regulationSessionId?: string | null;
|
||||
beforeMeasurementId?: string | null;
|
||||
afterMeasurementId?: string | null;
|
||||
cameraPosition: 'front' | 'back';
|
||||
plannedDurationSeconds: 180 | 300;
|
||||
actualDurationSeconds: number;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
completed: boolean;
|
||||
timeline: readonly CoherenceScoreSnapshot[];
|
||||
}
|
||||
|
||||
const finite = (value: number): boolean => Number.isFinite(value);
|
||||
const mean = (values: readonly number[]): number | null =>
|
||||
values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
const median = (values: readonly number[]): number | null => {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!;
|
||||
};
|
||||
|
||||
function compactTimeline(timeline: readonly CoherenceScoreSnapshot[]): CoherenceScoreSnapshot[] {
|
||||
const buckets = new Map<number, CoherenceScoreSnapshot>();
|
||||
timeline.forEach(point => {
|
||||
if (!finite(point.timestampSeconds) || !finite(point.quality)) return;
|
||||
const bucket = Math.max(0, Math.floor(point.timestampSeconds / 2) * 2);
|
||||
buckets.set(bucket, {...point, timestampSeconds: bucket});
|
||||
});
|
||||
return [...buckets.values()].sort((a, b) => a.timestampSeconds - b.timestampSeconds);
|
||||
}
|
||||
|
||||
function createRecord(input: SaveCoherenceSessionInput): StoredCoherenceSession {
|
||||
const timeline = compactTimeline(input.timeline);
|
||||
const scores = timeline.flatMap(point => point.score === null || !finite(point.score) ? [] : [point.score]);
|
||||
const qualities = timeline.map(point => point.quality).filter(finite);
|
||||
const bpms = timeline.flatMap(point => point.bpm === null || !finite(point.bpm) ? [] : [point.bpm]);
|
||||
const finalWindowStart = Math.max(0, input.actualDurationSeconds - 30);
|
||||
const finalScores = timeline
|
||||
.filter(point => point.timestampSeconds >= finalWindowStart)
|
||||
.flatMap(point => point.score === null ? [] : [point.score]);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: `coherence-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
algorithmVersion: COHERENCE_ALGORITHM_VERSION,
|
||||
regulationSessionId: input.regulationSessionId ?? null,
|
||||
beforeMeasurementId: input.beforeMeasurementId ?? null,
|
||||
afterMeasurementId: input.afterMeasurementId ?? null,
|
||||
cameraPosition: input.cameraPosition,
|
||||
plannedDurationSeconds: input.plannedDurationSeconds,
|
||||
actualDurationSeconds: Math.max(0, input.actualDurationSeconds),
|
||||
startedAt: input.startedAt,
|
||||
completedAt: input.completedAt,
|
||||
completed: input.completed,
|
||||
valid: scores.length >= 5,
|
||||
meanScore: mean(scores),
|
||||
finalScore: median(finalScores),
|
||||
bestScore: scores.length === 0 ? null : Math.max(...scores),
|
||||
validScoreSeconds: scores.length * 2,
|
||||
meanQuality: mean(qualities) ?? 0,
|
||||
initialBpm: bpms[0] ?? null,
|
||||
finalBpm: bpms.at(-1) ?? null,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function readAll(): Promise<StoredCoherenceSession[]> {
|
||||
const stored = await readJSON<unknown>(storageKeys.coherenceSessions, []);
|
||||
if (!Array.isArray(stored)) return [];
|
||||
return stored.filter((value): value is StoredCoherenceSession => {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Partial<StoredCoherenceSession>;
|
||||
return candidate.schemaVersion === 1 &&
|
||||
candidate.algorithmVersion === COHERENCE_ALGORITHM_VERSION &&
|
||||
typeof candidate.id === 'string' && Array.isArray(candidate.timeline);
|
||||
});
|
||||
}
|
||||
|
||||
export const coherenceSessionRepository = {
|
||||
async getAll(): Promise<StoredCoherenceSession[]> {
|
||||
await queue;
|
||||
return readAll();
|
||||
},
|
||||
|
||||
async save(input: SaveCoherenceSessionInput): Promise<StoredCoherenceSession> {
|
||||
const record = createRecord(input);
|
||||
const operation = queue.then(async () => {
|
||||
const current = await readAll();
|
||||
await writeJSON(storageKeys.coherenceSessions, [record, ...current]);
|
||||
return record;
|
||||
});
|
||||
queue = operation.then(() => undefined, () => undefined);
|
||||
return operation;
|
||||
},
|
||||
|
||||
async attachAfterMeasurement(sessionId: string, measurementId: string): Promise<void> {
|
||||
const operation = queue.then(async () => {
|
||||
const current = await readAll();
|
||||
await writeJSON(storageKeys.coherenceSessions, current.map(session =>
|
||||
session.id === sessionId ? {...session, afterMeasurementId: measurementId} : session,
|
||||
));
|
||||
});
|
||||
queue = operation.then(() => undefined, () => undefined);
|
||||
return operation;
|
||||
},
|
||||
|
||||
async attachRegulationSession(sessionId: string, regulationSessionId: string): Promise<void> {
|
||||
const operation = queue.then(async () => {
|
||||
const current = await readAll();
|
||||
await writeJSON(storageKeys.coherenceSessions, current.map(session =>
|
||||
session.id === sessionId ? {...session, regulationSessionId} : session,
|
||||
));
|
||||
});
|
||||
queue = operation.then(() => undefined, () => undefined);
|
||||
return operation;
|
||||
},
|
||||
|
||||
async deleteRelatedToMeasurement(measurementId: string): Promise<void> {
|
||||
const current = await this.getAll();
|
||||
await writeJSON(storageKeys.coherenceSessions, current.filter(session =>
|
||||
session.beforeMeasurementId !== measurementId && session.afterMeasurementId !== measurementId,
|
||||
));
|
||||
},
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
await writeJSON(storageKeys.coherenceSessions, []);
|
||||
},
|
||||
};
|
||||
|
||||
export const __coherenceRepositoryTesting = {createRecord, compactTimeline};
|
||||
@@ -0,0 +1,54 @@
|
||||
export const COHERENCE_ALGORITHM_VERSION = 'HAPTIKA_COHERENCE_V1.0.0';
|
||||
|
||||
export type CoherenceSessionDurationSeconds = 180 | 300;
|
||||
export type CoherenceScoreStatus =
|
||||
| 'calibrating'
|
||||
| 'poorSignal'
|
||||
| 'scored';
|
||||
|
||||
export interface CoherenceBeat {
|
||||
timestampSeconds: number;
|
||||
intervalMs: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface CoherenceScoreComponents {
|
||||
spectral: number;
|
||||
phase: number;
|
||||
regularity: number;
|
||||
}
|
||||
|
||||
export interface CoherenceScoreSnapshot {
|
||||
timestampSeconds: number;
|
||||
score: number | null;
|
||||
quality: number;
|
||||
bpm: number | null;
|
||||
status: CoherenceScoreStatus;
|
||||
components: CoherenceScoreComponents | null;
|
||||
validIntervalCount: number;
|
||||
coverage: number;
|
||||
}
|
||||
|
||||
export interface StoredCoherenceSession {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
algorithmVersion: typeof COHERENCE_ALGORITHM_VERSION;
|
||||
regulationSessionId: string | null;
|
||||
beforeMeasurementId: string | null;
|
||||
afterMeasurementId: string | null;
|
||||
cameraPosition: 'front' | 'back';
|
||||
plannedDurationSeconds: CoherenceSessionDurationSeconds;
|
||||
actualDurationSeconds: number;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
completed: boolean;
|
||||
valid: boolean;
|
||||
meanScore: number | null;
|
||||
finalScore: number | null;
|
||||
bestScore: number | null;
|
||||
validScoreSeconds: number;
|
||||
meanQuality: number;
|
||||
initialBpm: number | null;
|
||||
finalBpm: number | null;
|
||||
timeline: readonly CoherenceScoreSnapshot[];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,44 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import {
|
||||
defaultOnboardingAnswers,
|
||||
onboardingRepository,
|
||||
} from './onboardingRepository';
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const storage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
|
||||
|
||||
describe('onboardingRepository', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns no state on a fresh install', async () => {
|
||||
storage.getItem.mockResolvedValue(null);
|
||||
await expect(onboardingRepository.get()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('persists the completed profile', async () => {
|
||||
storage.setItem.mockResolvedValue();
|
||||
await onboardingRepository.complete({
|
||||
...defaultOnboardingAnswers,
|
||||
firstName: 'Sacha',
|
||||
birthYear: 1998,
|
||||
});
|
||||
const [, serialized] = storage.setItem.mock.calls[0] ?? [];
|
||||
const value = JSON.parse(serialized ?? '{}');
|
||||
expect(value).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
completed: true,
|
||||
firstName: 'Sacha',
|
||||
birthYear: 1998,
|
||||
});
|
||||
expect(value.completedAt).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const STORAGE_KEY = '@haptika/onboarding/v1';
|
||||
|
||||
export type OnboardingLevel = 'low' | 'medium' | 'high';
|
||||
export type OnboardingGender = 'female' | 'male' | null;
|
||||
export type OnboardingExperience = 'iphone' | 'watch';
|
||||
|
||||
export interface OnboardingAnswers {
|
||||
stress: OnboardingLevel;
|
||||
sleep: OnboardingLevel;
|
||||
energy: OnboardingLevel;
|
||||
firstName: string;
|
||||
birthYear: number | null;
|
||||
gender: OnboardingGender;
|
||||
experience: OnboardingExperience;
|
||||
healthKitRequested: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingState extends OnboardingAnswers {
|
||||
schemaVersion: 1;
|
||||
completed: boolean;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export const defaultOnboardingAnswers: OnboardingAnswers = {
|
||||
stress: 'low',
|
||||
sleep: 'medium',
|
||||
energy: 'high',
|
||||
firstName: '',
|
||||
birthYear: null,
|
||||
gender: null,
|
||||
experience: 'watch',
|
||||
healthKitRequested: false,
|
||||
};
|
||||
|
||||
function parseState(raw: string | null): OnboardingState | null {
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw) as Partial<OnboardingState>;
|
||||
if (value.schemaVersion !== 1) return null;
|
||||
return {
|
||||
...defaultOnboardingAnswers,
|
||||
...value,
|
||||
schemaVersion: 1,
|
||||
completed: value.completed === true,
|
||||
completedAt:
|
||||
typeof value.completedAt === 'string' ? value.completedAt : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(
|
||||
answers: OnboardingAnswers,
|
||||
completed: boolean,
|
||||
): Promise<void> {
|
||||
const state: OnboardingState = {
|
||||
...answers,
|
||||
schemaVersion: 1,
|
||||
completed,
|
||||
completedAt: completed ? new Date().toISOString() : null,
|
||||
};
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export const onboardingRepository = {
|
||||
async get(): Promise<OnboardingState | null> {
|
||||
return parseState(await AsyncStorage.getItem(STORAGE_KEY));
|
||||
},
|
||||
|
||||
async saveDraft(answers: OnboardingAnswers): Promise<void> {
|
||||
await persist(answers, false);
|
||||
},
|
||||
|
||||
async complete(answers: OnboardingAnswers): Promise<void> {
|
||||
await persist(answers, true);
|
||||
},
|
||||
|
||||
async reset(): Promise<void> {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,414 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {scheduleOnRN} from 'react-native-worklets';
|
||||
import {
|
||||
Camera,
|
||||
type CameraDevice,
|
||||
type CameraRef,
|
||||
useCameraDevices,
|
||||
useFrameOutput,
|
||||
} from 'react-native-vision-camera';
|
||||
import {haptikaPPG, type PPGFrameUpdate} from 'react-native-haptika-ppg';
|
||||
|
||||
import type {PPGCameraError} from './cameraTypes';
|
||||
|
||||
interface PPGCameraViewProps {
|
||||
isActive: boolean;
|
||||
cameraPosition?: 'back' | 'front';
|
||||
torchEnabled: boolean;
|
||||
lockOptics: boolean;
|
||||
onCameraStarted: () => void;
|
||||
onFrameUpdate: (update: PPGFrameUpdate) => void;
|
||||
onInterrupted: (reason: string) => void;
|
||||
onError: (error: PPGCameraError) => void;
|
||||
}
|
||||
|
||||
const PPG_TARGET_RESOLUTION = Object.freeze({width: 640, height: 480});
|
||||
|
||||
export function PPGCameraView({
|
||||
isActive,
|
||||
cameraPosition = 'back',
|
||||
torchEnabled,
|
||||
lockOptics,
|
||||
onCameraStarted,
|
||||
onFrameUpdate,
|
||||
onInterrupted,
|
||||
onError,
|
||||
}: PPGCameraViewProps): React.JSX.Element {
|
||||
const cameraRef = useRef<CameraRef>(null);
|
||||
const controllerRef = useRef<CameraRef['controller']>(undefined);
|
||||
const hasReportedTerminalEventRef = useRef(false);
|
||||
const torchConfiguredRef = useRef(false);
|
||||
const opticsLockedRef = useRef(false);
|
||||
const consecutiveFrameErrorsRef = useRef(0);
|
||||
const hasReceivedUpdateRef = useRef(false);
|
||||
const cameraDevices = useCameraDevices();
|
||||
const physicalBackDevices = cameraDevices.filter(
|
||||
candidate => candidate.position === 'back' && !candidate.isVirtualDevice,
|
||||
);
|
||||
const ultraWideDevice = physicalBackDevices.find(
|
||||
candidate => candidate.type === 'ultra-wide-angle' && candidate.hasTorch,
|
||||
);
|
||||
const virtualUltraWideDevice = cameraDevices.find(
|
||||
candidate =>
|
||||
candidate.position === 'back' &&
|
||||
candidate.isVirtualDevice &&
|
||||
candidate.hasTorch &&
|
||||
candidate.physicalDevices.some(
|
||||
physicalDevice => physicalDevice.type === 'ultra-wide-angle',
|
||||
) &&
|
||||
candidate.minZoom <= 0.5 &&
|
||||
candidate.maxZoom >= 0.5,
|
||||
);
|
||||
// Contact PPG apps commonly use the 0.5× module because its wider field of
|
||||
// view makes it easier to cover the lens and the flash with one fingertip.
|
||||
// Prefer that physical module when it exposes a torch, then fall back to the
|
||||
// 1× wide camera. Never use the virtual triple-camera device: it can switch
|
||||
// lenses during a measurement.
|
||||
const frontDevice = cameraDevices.find(
|
||||
candidate =>
|
||||
candidate.position === 'front' &&
|
||||
!candidate.isVirtualDevice &&
|
||||
candidate.type === 'wide-angle',
|
||||
) ?? cameraDevices.find(candidate => candidate.position === 'front');
|
||||
const device: CameraDevice | undefined =
|
||||
cameraPosition === 'front'
|
||||
? frontDevice
|
||||
: ultraWideDevice ??
|
||||
virtualUltraWideDevice ??
|
||||
physicalBackDevices.find(candidate => candidate.type === 'wide-angle');
|
||||
const initialZoom =
|
||||
device?.isVirtualDevice === true
|
||||
? Math.min(Math.max(0.5, device.minZoom), device.maxZoom)
|
||||
: undefined;
|
||||
|
||||
const forceTorchOff = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const controller = cameraRef.current?.controller ?? controllerRef.current;
|
||||
await controller?.setTorchMode('off');
|
||||
} catch {
|
||||
// Best effort: the session may already be torn down.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reportCameraError = useCallback(
|
||||
(error: PPGCameraError): void => {
|
||||
if (hasReportedTerminalEventRef.current) {
|
||||
return;
|
||||
}
|
||||
hasReportedTerminalEventRef.current = true;
|
||||
forceTorchOff();
|
||||
onError(error);
|
||||
},
|
||||
[forceTorchOff, onError],
|
||||
);
|
||||
|
||||
const reportInterruption = useCallback(
|
||||
(reason: string): void => {
|
||||
if (hasReportedTerminalEventRef.current) {
|
||||
return;
|
||||
}
|
||||
hasReportedTerminalEventRef.current = true;
|
||||
forceTorchOff();
|
||||
onInterrupted(reason);
|
||||
},
|
||||
[forceTorchOff, onInterrupted],
|
||||
);
|
||||
|
||||
const onFrameProcessorError = useCallback(
|
||||
(message: string): void => {
|
||||
consecutiveFrameErrorsRef.current += 1;
|
||||
// A camera can deliver a malformed frame while changing exposure. Skip
|
||||
// isolated failures; only a sustained broken pipeline ends the session.
|
||||
if (consecutiveFrameErrorsRef.current >= 10) {
|
||||
reportCameraError({code: 'cameraRuntime', message});
|
||||
}
|
||||
},
|
||||
[reportCameraError],
|
||||
);
|
||||
|
||||
const reportFrameUpdate = useCallback(
|
||||
(update: PPGFrameUpdate): void => {
|
||||
consecutiveFrameErrorsRef.current = 0;
|
||||
hasReceivedUpdateRef.current = true;
|
||||
onFrameUpdate(update);
|
||||
},
|
||||
[onFrameUpdate],
|
||||
);
|
||||
|
||||
const frameOutput = useFrameOutput({
|
||||
pixelFormat: 'yuv',
|
||||
targetResolution: PPG_TARGET_RESOLUTION,
|
||||
dropFramesWhileBusy: true,
|
||||
enablePhysicalBufferRotation: false,
|
||||
// VisionCamera logs every intentionally dropped busy frame when this
|
||||
// callback is omitted. Gaps are already measured by the native quality
|
||||
// evaluator, so keep the expected backpressure out of React Native LogBox.
|
||||
onFrameDropped() {},
|
||||
onFrame(frame) {
|
||||
'worklet';
|
||||
try {
|
||||
const update = haptikaPPG.processFrame(frame);
|
||||
if (update !== undefined) {
|
||||
scheduleOnRN(reportFrameUpdate, update);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Le traitement natif d’une image caméra a échoué.';
|
||||
scheduleOnRN(onFrameProcessorError, message);
|
||||
} finally {
|
||||
frame.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const outputs = useMemo(() => [frameOutput], [frameOutput]);
|
||||
const constraints = useMemo(() => [{fps: 30 as const}], []);
|
||||
|
||||
useEffect(() => {
|
||||
opticsLockedRef.current = false;
|
||||
consecutiveFrameErrorsRef.current = 0;
|
||||
hasReceivedUpdateRef.current = false;
|
||||
}, [device?.id, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || device === undefined) return;
|
||||
const watchdog = setTimeout(() => {
|
||||
if (!hasReceivedUpdateRef.current) {
|
||||
reportCameraError({
|
||||
code: 'cameraRuntime',
|
||||
message:
|
||||
'La caméra a démarré mais aucune trame n’atteint le capteur PPG.',
|
||||
});
|
||||
}
|
||||
}, 6_000);
|
||||
return () => clearTimeout(watchdog);
|
||||
}, [device, isActive, reportCameraError]);
|
||||
|
||||
const configureTorch = useCallback(
|
||||
async (): Promise<PPGCameraError | null> => {
|
||||
try {
|
||||
const controller =
|
||||
cameraRef.current?.controller ?? controllerRef.current;
|
||||
if (controller === undefined) {
|
||||
return {
|
||||
code: 'cameraRuntime',
|
||||
message: 'Le contrôleur de la caméra est indisponible.',
|
||||
};
|
||||
}
|
||||
const {device: activeDevice} = controller;
|
||||
if (!activeDevice.hasTorch) {
|
||||
return {
|
||||
code: 'torchUnavailable',
|
||||
message: 'Le flash de cet iPhone est indisponible.',
|
||||
};
|
||||
}
|
||||
|
||||
if (activeDevice.supportsTorchStrength) {
|
||||
// Full power clips the fingertip's red channel. Keep enough light for
|
||||
// PPG while preserving the small color changes caused by each pulse.
|
||||
const desiredStrength = Math.min(
|
||||
Math.max(0.15, activeDevice.minTorchStrength),
|
||||
activeDevice.maxTorchStrength,
|
||||
);
|
||||
try {
|
||||
if (
|
||||
controller.torchMode !== 'on' ||
|
||||
Math.abs(controller.torchStrength - desiredStrength) > 0.01
|
||||
) {
|
||||
await controller.enableTorchWithStrength(desiredStrength);
|
||||
}
|
||||
} catch {
|
||||
// Some camera combinations advertise strength control but reject
|
||||
// it at runtime. Fall back to the standard continuous torch.
|
||||
await controller.setTorchMode('on');
|
||||
}
|
||||
} else if (controller.torchMode !== 'on') {
|
||||
await controller.setTorchMode('on');
|
||||
}
|
||||
|
||||
if (!opticsLockedRef.current && activeDevice.supportsExposureBias) {
|
||||
try {
|
||||
const desiredBias = Math.min(
|
||||
Math.max(-1, activeDevice.minExposureBias),
|
||||
activeDevice.maxExposureBias,
|
||||
);
|
||||
if (Math.abs(controller.exposureBias - desiredBias) > 0.05) {
|
||||
await controller.setExposureBias(desiredBias);
|
||||
}
|
||||
} catch {
|
||||
// Exposure bias is optional; do not turn a torch capability issue
|
||||
// into a terminal camera error.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return {
|
||||
code: 'cameraRuntime',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Impossible de configurer le flash.',
|
||||
};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lockOptics || opticsLockedRef.current || !isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
const controller = cameraRef.current?.controller ?? controllerRef.current;
|
||||
if (cancelled || controller === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The finger is stable when this effect starts. Locking the automatic
|
||||
// camera controls stops AE/AWB/AF corrections from looking like pulse or
|
||||
// motion in the color time series.
|
||||
(async (): Promise<void> => {
|
||||
try {
|
||||
if (controller.device.supportsExposureLocking) {
|
||||
await controller.lockCurrentExposure();
|
||||
}
|
||||
} catch {
|
||||
// Optional stabilization; automatic exposure remains usable.
|
||||
}
|
||||
try {
|
||||
if (controller.device.supportsWhiteBalanceLocking) {
|
||||
await controller.lockCurrentWhiteBalance();
|
||||
}
|
||||
} catch {
|
||||
// Optional stabilization; automatic white balance remains usable.
|
||||
}
|
||||
try {
|
||||
if (controller.device.supportsFocusLocking) {
|
||||
await controller.lockCurrentFocus();
|
||||
}
|
||||
} catch {
|
||||
// Focus has little effect on spatially averaged fingertip color.
|
||||
}
|
||||
if (!cancelled) {
|
||||
opticsLockedRef.current = true;
|
||||
}
|
||||
})();
|
||||
}, 1_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isActive, lockOptics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (device?.position === 'front') {
|
||||
// The front-camera method is illuminated by the screen. It intentionally
|
||||
// has no torch and must never fail the measurement for that reason.
|
||||
torchConfiguredRef.current = false;
|
||||
forceTorchOff();
|
||||
return;
|
||||
}
|
||||
if (!torchEnabled || !isActive) {
|
||||
if (torchConfiguredRef.current) {
|
||||
torchConfiguredRef.current = false;
|
||||
forceTorchOff();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (torchConfiguredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
torchConfiguredRef.current = true;
|
||||
let cancelled = false;
|
||||
const ensureTorchIsOn = (): void => {
|
||||
configureTorch().then(error => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (error !== null) {
|
||||
torchConfiguredRef.current = false;
|
||||
reportCameraError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
ensureTorchIsOn();
|
||||
const torchWatchdog = setInterval(ensureTorchIsOn, 750);
|
||||
return () => {
|
||||
clearInterval(torchWatchdog);
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
configureTorch,
|
||||
forceTorchOff,
|
||||
isActive,
|
||||
device?.position,
|
||||
reportCameraError,
|
||||
torchEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
torchConfiguredRef.current = false;
|
||||
forceTorchOff();
|
||||
};
|
||||
}, [forceTorchOff]);
|
||||
|
||||
useEffect(() => {
|
||||
if (device !== undefined || !isActive) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
reportCameraError({
|
||||
code: 'cameraUnavailable',
|
||||
message:
|
||||
cameraPosition === 'front'
|
||||
? 'La caméra avant est indisponible.'
|
||||
: 'Aucune caméra arrière avec flash n’est disponible.',
|
||||
});
|
||||
}, 2_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cameraPosition, device, isActive, reportCameraError]);
|
||||
|
||||
if (device === undefined) {
|
||||
return <View style={styles.unavailable} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Camera
|
||||
ref={cameraRef}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
getInitialZoom={initialZoom === undefined ? undefined : () => initialZoom}
|
||||
isActive={isActive}
|
||||
outputs={outputs}
|
||||
constraints={constraints}
|
||||
onStarted={() => {
|
||||
controllerRef.current = cameraRef.current?.controller;
|
||||
onCameraStarted();
|
||||
}}
|
||||
onInterruptionStarted={reason => reportInterruption(String(reason))}
|
||||
onError={error =>
|
||||
reportCameraError({code: 'cameraRuntime', message: error.message})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
unavailable: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: '#08090B',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
FingerContactState,
|
||||
NativePPGConfiguration,
|
||||
NativePPGProcessingResult,
|
||||
PPGFrameUpdate,
|
||||
ThermalState,
|
||||
} from 'react-native-haptika-ppg';
|
||||
|
||||
export interface PPGCameraError {
|
||||
code: 'cameraUnavailable' | 'torchUnavailable' | 'cameraRuntime';
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {useCallback} from 'react';
|
||||
import {useCameraPermission} from 'react-native-vision-camera';
|
||||
|
||||
export interface PPGCameraPermissionController {
|
||||
hasCameraPermission: boolean;
|
||||
requestCameraPermission: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function usePPGCamera(): PPGCameraPermissionController {
|
||||
const {hasPermission, requestPermission} = useCameraPermission();
|
||||
|
||||
const requestCameraPermission = useCallback(async (): Promise<boolean> => {
|
||||
if (hasPermission) {
|
||||
return true;
|
||||
}
|
||||
return requestPermission();
|
||||
}, [hasPermission, requestPermission]);
|
||||
|
||||
return {
|
||||
hasCameraPermission: hasPermission,
|
||||
requestCameraPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
|
||||
interface ConfidenceIndicatorProps {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export function ConfidenceIndicator({
|
||||
confidence,
|
||||
}: ConfidenceIndicatorProps): React.JSX.Element {
|
||||
const percent = Math.round(Math.min(Math.max(confidence, 0), 1) * 100);
|
||||
const label = percent >= 75 ? 'Élevée' : percent >= 50 ? 'Modérée' : 'Faible';
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>Confiance</Text>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.fill, {width: `${percent}%`}]} />
|
||||
</View>
|
||||
<Text style={styles.percent}>{percent} %</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: spacing.md,
|
||||
borderRadius: radii.md,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
},
|
||||
eyebrow: {
|
||||
color: colors.muted,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
marginTop: 2,
|
||||
},
|
||||
right: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
track: {
|
||||
width: 84,
|
||||
height: 6,
|
||||
borderRadius: radii.pill,
|
||||
backgroundColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.blue,
|
||||
},
|
||||
percent: {
|
||||
color: colors.muted,
|
||||
fontVariant: ['tabular-nums'],
|
||||
width: 44,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
import type {FingerContactState} from '../measurement/measurementTypes';
|
||||
|
||||
interface FingerContactIndicatorProps {
|
||||
state: FingerContactState;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
const labels: Record<FingerContactState, string> = {
|
||||
absent: 'Doigt non détecté',
|
||||
adjusting: 'Ajustez votre doigt',
|
||||
valid: 'Contact stable',
|
||||
tooMuchPressure: 'Relâchez légèrement la pression',
|
||||
overexposed: 'Décalez légèrement votre doigt',
|
||||
unstable: 'Restez immobile',
|
||||
};
|
||||
|
||||
export function FingerContactIndicator({
|
||||
state,
|
||||
confidence,
|
||||
}: FingerContactIndicatorProps): React.JSX.Element {
|
||||
const isValid = state === 'valid';
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.dot, isValid ? styles.validDot : styles.pendingDot]} />
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.label}>{labels[state]}</Text>
|
||||
<Text style={styles.detail}>
|
||||
Stabilité {Math.round(Math.min(Math.max(confidence, 0), 1) * 100)} %
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'stretch',
|
||||
padding: spacing.md,
|
||||
borderRadius: radii.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: 'rgba(20,22,27,0.92)',
|
||||
},
|
||||
dot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
validDot: {
|
||||
backgroundColor: colors.good,
|
||||
},
|
||||
pendingDot: {
|
||||
backgroundColor: colors.medium,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
fontSize: 15,
|
||||
},
|
||||
detail: {
|
||||
color: colors.muted,
|
||||
fontSize: 12,
|
||||
marginTop: 3,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
import {
|
||||
FirstAidKit,
|
||||
GearSix,
|
||||
List,
|
||||
Toolbox,
|
||||
TrendUp,
|
||||
User,
|
||||
Waveform,
|
||||
} from 'phosphor-react-native';
|
||||
|
||||
import {colors} from '../../../theme';
|
||||
|
||||
type IconComponent = React.ComponentType<{
|
||||
color?: string;
|
||||
size?: number;
|
||||
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone';
|
||||
}>;
|
||||
|
||||
interface HaptikaHeaderProps {
|
||||
onMenuPress?: () => void;
|
||||
onSettingsPress?: () => void;
|
||||
}
|
||||
|
||||
export function HaptikaHeader({
|
||||
onMenuPress,
|
||||
onSettingsPress,
|
||||
}: HaptikaHeaderProps): React.JSX.Element {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{height: 64 + insets.top, paddingTop: insets.top},
|
||||
]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Ouvrir le menu"
|
||||
hitSlop={12}
|
||||
onPress={onMenuPress}
|
||||
style={styles.headerButton}>
|
||||
<List color={colors.accent} size={20} weight="regular" />
|
||||
</Pressable>
|
||||
<Text style={styles.brand}>HAPTIKA</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Ouvrir les réglages"
|
||||
hitSlop={12}
|
||||
onPress={onSettingsPress}
|
||||
style={styles.headerButton}>
|
||||
<GearSix color={colors.accent} size={19} weight="regular" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface BottomNavigationProps {
|
||||
onHistory: () => void;
|
||||
onSos: () => void;
|
||||
onTools: () => void;
|
||||
}
|
||||
|
||||
const items: readonly {
|
||||
label: string;
|
||||
icon: IconComponent;
|
||||
key: 'hrv' | 'sos' | 'tools' | 'progress' | 'profile';
|
||||
}[] = [
|
||||
{key: 'hrv', label: 'HRV', icon: Waveform},
|
||||
{key: 'sos', label: 'SOS', icon: FirstAidKit},
|
||||
{key: 'tools', label: 'Outils', icon: Toolbox},
|
||||
{key: 'progress', label: 'Évolution', icon: TrendUp},
|
||||
{key: 'profile', label: 'Profil', icon: User},
|
||||
];
|
||||
|
||||
export function BottomNavigation({
|
||||
onHistory,
|
||||
onSos,
|
||||
onTools,
|
||||
}: BottomNavigationProps): React.JSX.Element {
|
||||
const actionFor = (key: (typeof items)[number]['key']): (() => void) => {
|
||||
if (key === 'progress') return onHistory;
|
||||
if (key === 'sos') return onSos;
|
||||
if (key === 'tools') return onTools;
|
||||
return () => undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.bottomNavigation}>
|
||||
{items.map(item => {
|
||||
const Icon = item.icon;
|
||||
const selected = item.key === 'hrv';
|
||||
return (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={item.label}
|
||||
onPress={actionFor(item.key)}
|
||||
style={styles.navItem}>
|
||||
<Icon
|
||||
color={selected ? colors.accent : '#B7C2D1'}
|
||||
size={22}
|
||||
weight={selected ? 'regular' : 'bold'}
|
||||
/>
|
||||
<Text style={[styles.navLabel, selected && styles.navLabelSelected]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
height: 64,
|
||||
flexShrink: 0,
|
||||
paddingHorizontal: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: 'rgba(98,212,255,0.12)',
|
||||
backgroundColor: '#071321',
|
||||
},
|
||||
headerButton: {
|
||||
width: 32,
|
||||
height: 40,
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
brand: {
|
||||
color: '#DCE5F3',
|
||||
fontSize: 27,
|
||||
lineHeight: 34,
|
||||
fontWeight: '300',
|
||||
letterSpacing: 3.1,
|
||||
},
|
||||
bottomNavigation: {
|
||||
height: 80,
|
||||
flexShrink: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(168,201,232,0.16)',
|
||||
backgroundColor: '#121C2A',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
navItem: {
|
||||
minWidth: 60,
|
||||
height: 58,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
navLabel: {
|
||||
color: '#B7C2D1',
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
},
|
||||
navLabelSelected: {
|
||||
color: colors.accent,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
|
||||
interface MeasurementProgressProps {
|
||||
progress: number;
|
||||
label: string;
|
||||
secondsRemaining?: number;
|
||||
}
|
||||
|
||||
export function MeasurementProgress({
|
||||
progress,
|
||||
label,
|
||||
secondsRemaining,
|
||||
}: MeasurementProgressProps): React.JSX.Element {
|
||||
const normalized = Math.min(Math.max(progress, 0), 1);
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{secondsRemaining !== undefined ? (
|
||||
<Text style={styles.time}>{Math.max(0, Math.ceil(secondsRemaining))} s</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityValue={{min: 0, max: 100, now: Math.round(normalized * 100)}}
|
||||
style={styles.track}>
|
||||
<View style={[styles.fill, {width: `${normalized * 100}%`}]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
time: {
|
||||
color: colors.muted,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
track: {
|
||||
height: 8,
|
||||
borderRadius: radii.pill,
|
||||
backgroundColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
borderRadius: radii.pill,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
import {colors, spacing} from '../../../theme';
|
||||
import type {MetricInsight, MetricInsightTone} from '../insights/metricInsights';
|
||||
import {MetricTrendChart} from './MetricTrendChart';
|
||||
|
||||
const toneColor: Record<MetricInsightTone, string> = {
|
||||
good: colors.good,
|
||||
neutral: colors.accent,
|
||||
attention: '#F5C66A',
|
||||
unavailable: colors.muted,
|
||||
};
|
||||
|
||||
export function MetricInsightCard({
|
||||
insight,
|
||||
defaultExpanded = false,
|
||||
}: {
|
||||
insight: MetricInsight;
|
||||
defaultExpanded?: boolean;
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const color = toneColor[insight.tone];
|
||||
const value =
|
||||
insight.value === null ? '—' : insight.value.toFixed(insight.digits);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{expanded}}
|
||||
accessibilityLabel={`${insight.label}, ${value} ${insight.unit}, ${insight.status}`}
|
||||
onPress={() => setExpanded(current => !current)}
|
||||
style={({pressed}) => [styles.header, pressed && styles.pressed]}>
|
||||
<View style={styles.valueBlock}>
|
||||
<View style={styles.valueRow}>
|
||||
<Text style={[styles.value, {textDecorationColor: color}]}>{value}</Text>
|
||||
<Text style={styles.unit}>{insight.unit}</Text>
|
||||
<Text style={styles.label}>{insight.shortLabel}</Text>
|
||||
</View>
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.dot, {backgroundColor: color}]} />
|
||||
<Text style={[styles.status, {color}]}>{insight.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.chevronCircle}>
|
||||
<Text style={styles.chevron}>{expanded ? '−' : '+'}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{expanded ? (
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.description}>{insight.description}</Text>
|
||||
<MetricTrendChart
|
||||
points={insight.trend}
|
||||
color={color}
|
||||
unit={insight.unit}
|
||||
digits={insight.digits}
|
||||
referenceRange={insight.referenceRange}
|
||||
/>
|
||||
<Text style={styles.methodology}>{insight.methodology}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
header: {
|
||||
minHeight: 52,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
pressed: {opacity: 0.72},
|
||||
valueBlock: {flex: 1},
|
||||
valueRow: {flexDirection: 'row', alignItems: 'baseline', flexWrap: 'wrap'},
|
||||
value: {
|
||||
color: colors.text,
|
||||
fontSize: 30,
|
||||
fontWeight: '900',
|
||||
letterSpacing: -1,
|
||||
fontVariant: ['tabular-nums'],
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
unit: {color: colors.text, fontSize: 17, marginLeft: 4, fontWeight: '700'},
|
||||
label: {color: colors.text, fontSize: 19, marginLeft: spacing.md, fontWeight: '600'},
|
||||
statusRow: {flexDirection: 'row', alignItems: 'center', marginTop: 4},
|
||||
dot: {width: 7, height: 7, borderRadius: 4, marginRight: spacing.xs},
|
||||
status: {fontSize: 12, fontWeight: '700'},
|
||||
chevronCircle: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
chevron: {color: colors.muted, fontSize: 18, fontWeight: '500'},
|
||||
body: {paddingTop: spacing.sm},
|
||||
description: {color: colors.text, fontSize: 15, lineHeight: 22, marginBottom: spacing.sm},
|
||||
methodology: {color: colors.muted, fontSize: 10, lineHeight: 15, marginTop: spacing.sm},
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
import Svg, {Circle, Line, Path, Rect} from 'react-native-svg';
|
||||
|
||||
import {colors, spacing} from '../../../theme';
|
||||
import type {MetricTrendPoint} from '../insights/metricInsights';
|
||||
|
||||
interface MetricTrendChartProps {
|
||||
points: readonly MetricTrendPoint[];
|
||||
color: string;
|
||||
unit: string;
|
||||
digits: number;
|
||||
referenceRange: {minimum: number; maximum: number} | null;
|
||||
}
|
||||
|
||||
const WIDTH = 320;
|
||||
const HEIGHT = 104;
|
||||
const PAD_X = 8;
|
||||
const PAD_Y = 13;
|
||||
|
||||
const dateLabel = (value: string): string =>
|
||||
new Date(value).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
});
|
||||
|
||||
const createPath = (coordinates: readonly {x: number; y: number}[]): string => {
|
||||
if (coordinates.length === 0) return '';
|
||||
if (coordinates.length === 1) {
|
||||
const only = coordinates[0]!;
|
||||
return `M ${only.x} ${only.y}`;
|
||||
}
|
||||
return coordinates.reduce((path, point, index) => {
|
||||
if (index === 0) return `M ${point.x} ${point.y}`;
|
||||
const previous = coordinates[index - 1]!;
|
||||
const controlX = (previous.x + point.x) / 2;
|
||||
return `${path} C ${controlX} ${previous.y}, ${controlX} ${point.y}, ${point.x} ${point.y}`;
|
||||
}, '');
|
||||
};
|
||||
|
||||
export function MetricTrendChart({
|
||||
points,
|
||||
color,
|
||||
unit,
|
||||
digits,
|
||||
referenceRange,
|
||||
}: MetricTrendChartProps): React.JSX.Element {
|
||||
const model = useMemo(() => {
|
||||
if (points.length === 0) return null;
|
||||
const allValues = [
|
||||
...points.map(point => point.value),
|
||||
...(referenceRange === null
|
||||
? []
|
||||
: [referenceRange.minimum, referenceRange.maximum]),
|
||||
];
|
||||
const rawMinimum = Math.min(...allValues);
|
||||
const rawMaximum = Math.max(...allValues);
|
||||
const padding = Math.max((rawMaximum - rawMinimum) * 0.18, Math.abs(rawMaximum) * 0.02, 0.5);
|
||||
const minimum = rawMinimum - padding;
|
||||
const maximum = rawMaximum + padding;
|
||||
const scaleY = (value: number): number =>
|
||||
PAD_Y + ((maximum - value) / Math.max(maximum - minimum, 0.0001)) * (HEIGHT - PAD_Y * 2);
|
||||
const coordinates = points.map((point, index) => ({
|
||||
x:
|
||||
points.length === 1
|
||||
? WIDTH / 2
|
||||
: PAD_X + (index / (points.length - 1)) * (WIDTH - PAD_X * 2),
|
||||
y: scaleY(point.value),
|
||||
}));
|
||||
return {
|
||||
coordinates,
|
||||
path: createPath(coordinates),
|
||||
rangeY:
|
||||
referenceRange === null
|
||||
? null
|
||||
: {
|
||||
top: scaleY(referenceRange.maximum),
|
||||
bottom: scaleY(referenceRange.minimum),
|
||||
},
|
||||
};
|
||||
}, [points, referenceRange]);
|
||||
|
||||
if (model === null || points.length < 2) {
|
||||
return (
|
||||
<View style={styles.empty} accessibilityLabel="Tendance indisponible">
|
||||
<Text style={styles.emptyTitle}>Tendance en construction</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Encore {Math.max(0, 2 - points.length)} mesure comparable pour tracer une évolution.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const first = points[0]!;
|
||||
const last = points[points.length - 1]!;
|
||||
const firstCoordinate = model.coordinates[0]!;
|
||||
const lastCoordinate = model.coordinates[model.coordinates.length - 1]!;
|
||||
const valueLabel = `${last.value.toFixed(digits)} ${unit}`;
|
||||
|
||||
return (
|
||||
<View
|
||||
accessible
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Tendance de ${points.length} mesures, de ${first.value.toFixed(digits)} à ${valueLabel}`}>
|
||||
<Svg width="100%" height={HEIGHT} viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
|
||||
{model.rangeY === null ? null : (
|
||||
<Rect
|
||||
x={PAD_X}
|
||||
y={model.rangeY.top}
|
||||
width={WIDTH - PAD_X * 2}
|
||||
height={Math.max(2, model.rangeY.bottom - model.rangeY.top)}
|
||||
fill={colors.accent}
|
||||
opacity={0.08}
|
||||
rx={4}
|
||||
/>
|
||||
)}
|
||||
<Line x1={PAD_X} y1={HEIGHT - PAD_Y} x2={WIDTH - PAD_X} y2={HEIGHT - PAD_Y} stroke={colors.border} strokeWidth={1} />
|
||||
<Line x1={firstCoordinate.x} y1={PAD_Y} x2={firstCoordinate.x} y2={HEIGHT - PAD_Y} stroke={colors.border} strokeWidth={1} />
|
||||
<Line x1={lastCoordinate.x} y1={PAD_Y} x2={lastCoordinate.x} y2={HEIGHT - PAD_Y} stroke={colors.border} strokeWidth={1} />
|
||||
<Path d={model.path} fill="none" stroke={color} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{model.coordinates.map((coordinate, index) => (
|
||||
<Circle
|
||||
key={points[index]!.id}
|
||||
cx={coordinate.x}
|
||||
cy={coordinate.y}
|
||||
r={index === 0 || index === model.coordinates.length - 1 ? 4 : 2.2}
|
||||
fill={color}
|
||||
opacity={index === 0 || index === model.coordinates.length - 1 ? 1 : 0.62}
|
||||
/>
|
||||
))}
|
||||
</Svg>
|
||||
<View style={styles.labels}>
|
||||
<Text style={styles.date}>{dateLabel(first.measuredAt)}</Text>
|
||||
<Text style={[styles.lastValue, {color}]}>{valueLabel}</Text>
|
||||
<Text style={styles.date}>{dateLabel(last.measuredAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.caption}>
|
||||
{points.length} mesures réelles{referenceRange === null ? '' : ' · zone pâle : repère personnel'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
empty: {
|
||||
minHeight: 96,
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
emptyTitle: {color: colors.text, fontSize: 13, fontWeight: '700'},
|
||||
emptyText: {color: colors.muted, fontSize: 11, lineHeight: 16, marginTop: 3},
|
||||
labels: {
|
||||
marginTop: -2,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
date: {color: colors.muted, fontSize: 10},
|
||||
lastValue: {fontSize: 11, fontWeight: '800', fontVariant: ['tabular-nums']},
|
||||
caption: {color: colors.muted, fontSize: 9, marginTop: spacing.xs},
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import React from 'react';
|
||||
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
||||
import {Clock, Play, TrendUp, Wind} from 'phosphor-react-native';
|
||||
|
||||
import {colors} from '../../../theme';
|
||||
import type {HapticRegulationProtocol} from '../../cardiacMirror/regulationProtocols';
|
||||
import type {PPGProcessingResult} from '../measurement/measurementTypes';
|
||||
import type {PotentialStressResult} from '../stress/stressTypes';
|
||||
|
||||
interface PPGResultSummaryProps {
|
||||
cameraResult: PPGProcessingResult;
|
||||
potentialStress: PotentialStressResult;
|
||||
recommendedProtocol: HapticRegulationProtocol;
|
||||
onStartRegulation: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number): number =>
|
||||
Math.min(Math.max(value, minimum), maximum);
|
||||
|
||||
export function PPGResultSummary({
|
||||
cameraResult,
|
||||
potentialStress,
|
||||
recommendedProtocol,
|
||||
onStartRegulation,
|
||||
onDone,
|
||||
}: PPGResultSummaryProps): React.JSX.Element {
|
||||
const score = Math.round(
|
||||
clamp(100 - (potentialStress.index ?? 50), 0, 100),
|
||||
);
|
||||
const qualityPercent = Math.round(clamp(cameraResult.quality.score, 0, 1) * 100);
|
||||
const bpm = cameraResult.bpm.bpm === null ? '—' : Math.round(cameraResult.bpm.bpm);
|
||||
const rmssd =
|
||||
cameraResult.prv.reliable && cameraResult.prv.estimatedRmssdMs !== null
|
||||
? Math.round(cameraResult.prv.estimatedRmssdMs)
|
||||
: '—';
|
||||
const balanceLabel =
|
||||
score >= 62 ? 'Équilibre favorable' : score >= 42 ? 'Équilibre intermédiaire' : 'Activation élevée';
|
||||
const cursorPosition = clamp(score, 4, 94);
|
||||
|
||||
return (
|
||||
<View style={styles.summary}>
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.kicker}>VOTRE ÉTAT ACTUEL</Text>
|
||||
<Text style={styles.score}>{score}</Text>
|
||||
<View style={styles.balanceRow}>
|
||||
<TrendUp color="#CCD6E4" size={13} weight="bold" />
|
||||
<Text style={styles.balanceText}>{balanceLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.axisCard}>
|
||||
<View style={styles.axisLabels}>
|
||||
<View>
|
||||
<Text style={styles.axisEyebrow}>SYMPATHIQUE</Text>
|
||||
<Text style={styles.axisValue}>Stress / Action</Text>
|
||||
</View>
|
||||
<View style={styles.axisRight}>
|
||||
<Text style={styles.axisEyebrow}>PARASYMPATHIQUE</Text>
|
||||
<Text style={styles.axisRecovery}>Récupération</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.axisTrack}>
|
||||
<View style={styles.centerDivider} />
|
||||
<View style={[styles.axisCursor, {left: `${cursorPosition}%`}]} />
|
||||
</View>
|
||||
<Text style={styles.axisInterpretation}>
|
||||
Votre système nerveux montre une excellente capacité{`\n`}de régulation.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.metricsRow}>
|
||||
<Metric label="HRV" value={rmssd === '—' ? '—' : `${rmssd} ms`} status="HAUT" accent />
|
||||
<Metric label="PULSATION" value={`${bpm} bpm`} status="STABLE" />
|
||||
<Metric label="QUALITÉ" value={qualityPercent >= 75 ? 'Bonne' : 'Moyenne'} status={`${qualityPercent}%`} accent />
|
||||
</View>
|
||||
|
||||
<View style={styles.recommendationCard}>
|
||||
<View style={styles.recommendationCopy}>
|
||||
<Text style={styles.recommendationKicker}>RECOMMANDATION</Text>
|
||||
<Text style={styles.recommendationTitle}>{recommendedProtocol.title}</Text>
|
||||
<View style={styles.durationRow}>
|
||||
<Clock color="#B5C1CF" size={14} weight="regular" />
|
||||
<Text style={styles.durationText}>
|
||||
{Math.round(recommendedProtocol.durationSeconds / 60)} min
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.windBadge}>
|
||||
<Wind color={colors.accent} size={27} weight="regular" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onStartRegulation}
|
||||
style={({pressed}) => [styles.launchButton, pressed && styles.pressed]}>
|
||||
<Play color="#07304A" size={13} weight="fill" />
|
||||
<Text style={styles.launchText}>LANCER LA SÉANCE</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={onDone} style={styles.doneButton}>
|
||||
<Text style={styles.doneText}>TERMINER</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
status,
|
||||
accent = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
status: string;
|
||||
accent?: boolean;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<View style={styles.metricCard}>
|
||||
<Text style={styles.metricLabel}>{label}</Text>
|
||||
<Text style={[styles.metricValue, accent && styles.metricValueAccent]}>{value}</Text>
|
||||
<Text style={[styles.metricStatus, accent && styles.metricStatusAccent]}>{status}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
summary: {
|
||||
alignSelf: 'stretch',
|
||||
minHeight: 850,
|
||||
marginHorizontal: -24,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
hero: {height: 177, alignItems: 'center'},
|
||||
kicker: {
|
||||
color: colors.accent,
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.3,
|
||||
},
|
||||
score: {
|
||||
color: '#DEE6F2',
|
||||
fontSize: 62,
|
||||
lineHeight: 80,
|
||||
fontWeight: '200',
|
||||
letterSpacing: -2.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
marginTop: 20,
|
||||
},
|
||||
balanceRow: {height: 22, flexDirection: 'row', alignItems: 'center', gap: 8},
|
||||
balanceText: {color: '#D0D8E4', fontSize: 17, fontWeight: '700'},
|
||||
axisCard: {
|
||||
height: 152,
|
||||
borderRadius: 13,
|
||||
borderWidth: 1,
|
||||
borderColor: '#17466F',
|
||||
backgroundColor: '#07192B',
|
||||
paddingHorizontal: 17,
|
||||
paddingTop: 17,
|
||||
},
|
||||
axisLabels: {height: 30, flexDirection: 'row', justifyContent: 'space-between'},
|
||||
axisRight: {alignItems: 'flex-end'},
|
||||
axisEyebrow: {color: '#8F9AA9', fontSize: 10, lineHeight: 14, letterSpacing: 1.2},
|
||||
axisValue: {color: '#D9E2EE', fontSize: 12, lineHeight: 16},
|
||||
axisRecovery: {color: colors.accent, fontSize: 12, lineHeight: 16},
|
||||
axisTrack: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginTop: 24,
|
||||
backgroundColor: '#62D4FF',
|
||||
borderLeftWidth: 98,
|
||||
borderLeftColor: '#F25E68',
|
||||
},
|
||||
centerDivider: {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: -4,
|
||||
width: 1,
|
||||
height: 16,
|
||||
backgroundColor: '#72808F',
|
||||
},
|
||||
axisCursor: {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginLeft: -12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 3,
|
||||
borderColor: '#C7D7E9',
|
||||
backgroundColor: '#132236',
|
||||
},
|
||||
axisInterpretation: {
|
||||
color: '#AAB6C4',
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
marginTop: 22,
|
||||
},
|
||||
metricsRow: {height: 94, flexDirection: 'row', gap: 13, marginTop: 24},
|
||||
metricCard: {
|
||||
flex: 1,
|
||||
borderRadius: 13,
|
||||
borderWidth: 1,
|
||||
borderColor: '#17466F',
|
||||
backgroundColor: '#07192B',
|
||||
alignItems: 'center',
|
||||
paddingTop: 17,
|
||||
},
|
||||
metricLabel: {color: '#B7C1CE', fontSize: 10, lineHeight: 14, letterSpacing: 1.2},
|
||||
metricValue: {color: '#D9E2EE', fontSize: 17, lineHeight: 22, fontWeight: '700'},
|
||||
metricValueAccent: {color: colors.accent},
|
||||
metricStatus: {color: '#8C98A7', fontSize: 9, fontWeight: '800', marginTop: 5},
|
||||
metricStatusAccent: {color: colors.accent},
|
||||
recommendationCard: {
|
||||
height: 108,
|
||||
marginTop: 24,
|
||||
borderRadius: 13,
|
||||
borderWidth: 1,
|
||||
borderColor: '#17466F',
|
||||
backgroundColor: '#07192B',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 17,
|
||||
},
|
||||
recommendationCopy: {height: 74, justifyContent: 'center'},
|
||||
recommendationKicker: {
|
||||
color: colors.accent,
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
recommendationTitle: {color: '#DDE5F0', fontSize: 17, lineHeight: 22, fontWeight: '700', marginTop: 5},
|
||||
durationRow: {flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 5},
|
||||
durationText: {color: '#B6C1CF', fontSize: 12},
|
||||
windBadge: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2B688C',
|
||||
backgroundColor: '#0A263A',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actions: {height: 112, marginTop: 24},
|
||||
launchButton: {
|
||||
height: 52,
|
||||
borderRadius: 13,
|
||||
backgroundColor: '#75D3FA',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
shadowColor: colors.accent,
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: {width: 0, height: 8},
|
||||
},
|
||||
launchText: {color: '#07304A', fontSize: 11, fontWeight: '800'},
|
||||
doneButton: {
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
marginTop: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#122A3D',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
doneText: {color: '#C4CEDB', fontSize: 11},
|
||||
pressed: {opacity: 0.76, transform: [{scale: 0.99}]},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
|
||||
interface PotentialStressGaugeProps {
|
||||
index: number | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function PotentialStressGauge({
|
||||
index,
|
||||
label,
|
||||
}: PotentialStressGaugeProps): React.JSX.Element {
|
||||
const normalized = index === null ? 0 : Math.min(Math.max(index, 0), 100);
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.valueRow}>
|
||||
<Text style={styles.value}>{index === null ? '—' : index}</Text>
|
||||
{index !== null ? <Text style={styles.outOf}>/100</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.fill, {width: `${normalized}%`}]} />
|
||||
</View>
|
||||
<View style={styles.scale}>
|
||||
<Text style={styles.scaleText}>Habituel</Text>
|
||||
<Text style={styles.scaleText}>Activation potentielle</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radii.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
valueRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
value: {
|
||||
color: colors.text,
|
||||
fontSize: 54,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -2,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
outOf: {
|
||||
color: colors.muted,
|
||||
fontSize: 16,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
track: {
|
||||
marginTop: spacing.lg,
|
||||
height: 9,
|
||||
borderRadius: radii.pill,
|
||||
backgroundColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
scale: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
scaleText: {
|
||||
color: colors.muted,
|
||||
fontSize: 10,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
label,
|
||||
onPress,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
variant = 'primary',
|
||||
style,
|
||||
}: PrimaryButtonProps): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
disabled={disabled || loading}
|
||||
onPress={onPress}
|
||||
style={({pressed}) => [
|
||||
styles.base,
|
||||
styles[variant],
|
||||
(disabled || loading) && styles.disabled,
|
||||
pressed && styles.pressed,
|
||||
style,
|
||||
]}>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
color={variant === 'primary' ? colors.background : colors.text}
|
||||
/>
|
||||
) : (
|
||||
<Text style={[styles.label, styles[`${variant}Label`]]}>{label}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
minHeight: 54,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: radii.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
primaryLabel: {
|
||||
color: colors.background,
|
||||
},
|
||||
secondaryLabel: {
|
||||
color: colors.text,
|
||||
},
|
||||
ghostLabel: {
|
||||
color: colors.muted,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.78,
|
||||
transform: [{scale: 0.99}],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
import type {SignalQualityLevel} from '../measurement/measurementTypes';
|
||||
|
||||
interface SignalQualityBadgeProps {
|
||||
level: SignalQualityLevel;
|
||||
score: number;
|
||||
}
|
||||
|
||||
const labels: Record<SignalQualityLevel, string> = {
|
||||
good: 'Bonne qualité',
|
||||
medium: 'Qualité moyenne',
|
||||
poor: 'Qualité faible',
|
||||
invalid: 'Signal invalide',
|
||||
};
|
||||
|
||||
const levelColor: Record<SignalQualityLevel, string> = {
|
||||
good: colors.good,
|
||||
medium: colors.medium,
|
||||
poor: colors.poor,
|
||||
invalid: colors.poor,
|
||||
};
|
||||
|
||||
export function SignalQualityBadge({
|
||||
level,
|
||||
score,
|
||||
}: SignalQualityBadgeProps): React.JSX.Element {
|
||||
return (
|
||||
<View style={[styles.badge, {borderColor: levelColor[level]}]}>
|
||||
<View style={[styles.dot, {backgroundColor: levelColor[level]}]} />
|
||||
<Text style={styles.label}>{labels[level]}</Text>
|
||||
<Text style={styles.score}>{Math.round(score * 100)} %</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.pill,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
dot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
marginRight: 7,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
score: {
|
||||
color: colors.muted,
|
||||
fontSize: 12,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import type {
|
||||
HealthContextEvaluation,
|
||||
HealthContextIssue,
|
||||
HealthKitContext,
|
||||
HealthMetricEvaluation,
|
||||
HealthMetricIssue,
|
||||
HealthMetricSample,
|
||||
HealthMetricType,
|
||||
} from './healthTypes';
|
||||
|
||||
export interface HealthMetricRecencyThreshold {
|
||||
freshSeconds: number;
|
||||
usableSeconds: number;
|
||||
}
|
||||
|
||||
export interface HealthContextEvaluatorConfiguration {
|
||||
futureToleranceSeconds: number;
|
||||
recencyByType: Readonly<
|
||||
Record<HealthMetricType, HealthMetricRecencyThreshold>
|
||||
>;
|
||||
}
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
export const DEFAULT_HEALTH_CONTEXT_EVALUATOR_CONFIGURATION: Readonly<HealthContextEvaluatorConfiguration> =
|
||||
Object.freeze({
|
||||
futureToleranceSeconds: 5 * MINUTE,
|
||||
recencyByType: Object.freeze({
|
||||
heartRate: Object.freeze({
|
||||
freshSeconds: 15 * MINUTE,
|
||||
usableSeconds: 24 * HOUR,
|
||||
}),
|
||||
restingHeartRate: Object.freeze({
|
||||
freshSeconds: 2 * DAY,
|
||||
usableSeconds: 7 * DAY,
|
||||
}),
|
||||
hrvSdnn: Object.freeze({
|
||||
freshSeconds: 24 * HOUR,
|
||||
usableSeconds: 7 * DAY,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const clamp01 = (value: number): number =>
|
||||
Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0;
|
||||
|
||||
const invalidEvaluation = (
|
||||
sample: HealthMetricSample,
|
||||
issues: readonly HealthMetricIssue[],
|
||||
relevance: HealthMetricEvaluation['relevance'] = 'invalid',
|
||||
): HealthMetricEvaluation => ({
|
||||
sample,
|
||||
ageSeconds: null,
|
||||
relevance,
|
||||
confidence: 0,
|
||||
usableAsContext: false,
|
||||
canRepresentCurrentMeasurement: false,
|
||||
issues,
|
||||
});
|
||||
|
||||
export const evaluateHealthMetricSample = (
|
||||
sample: HealthMetricSample,
|
||||
now: Date = new Date(),
|
||||
configuration: HealthContextEvaluatorConfiguration =
|
||||
DEFAULT_HEALTH_CONTEXT_EVALUATOR_CONFIGURATION,
|
||||
): HealthMetricEvaluation => {
|
||||
if (!Number.isFinite(sample.value)) {
|
||||
return invalidEvaluation(sample, ['nonFiniteValue']);
|
||||
}
|
||||
if (sample.value <= 0) {
|
||||
return invalidEvaluation(sample, ['nonPositiveValue']);
|
||||
}
|
||||
|
||||
const startMilliseconds = Date.parse(sample.startDate);
|
||||
const endMilliseconds = Date.parse(sample.endDate);
|
||||
const nowMilliseconds = now.getTime();
|
||||
if (
|
||||
!Number.isFinite(startMilliseconds) ||
|
||||
!Number.isFinite(endMilliseconds) ||
|
||||
!Number.isFinite(nowMilliseconds)
|
||||
) {
|
||||
return invalidEvaluation(sample, ['invalidDate']);
|
||||
}
|
||||
if (endMilliseconds < startMilliseconds) {
|
||||
return invalidEvaluation(sample, ['endBeforeStart']);
|
||||
}
|
||||
|
||||
const rawAgeSeconds = (nowMilliseconds - endMilliseconds) / 1_000;
|
||||
if (rawAgeSeconds < -configuration.futureToleranceSeconds) {
|
||||
return invalidEvaluation(sample, ['futureDated'], 'futureDated');
|
||||
}
|
||||
|
||||
const ageSeconds = Math.max(rawAgeSeconds, 0);
|
||||
const threshold = configuration.recencyByType[sample.type];
|
||||
if (ageSeconds <= threshold.freshSeconds) {
|
||||
const freshnessRatio =
|
||||
threshold.freshSeconds === 0
|
||||
? 0
|
||||
: ageSeconds / threshold.freshSeconds;
|
||||
return {
|
||||
sample,
|
||||
ageSeconds,
|
||||
relevance: 'freshContext',
|
||||
confidence: clamp01(1 - 0.25 * freshnessRatio),
|
||||
usableAsContext: true,
|
||||
canRepresentCurrentMeasurement: false,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (ageSeconds <= threshold.usableSeconds) {
|
||||
const recencyRange = threshold.usableSeconds - threshold.freshSeconds;
|
||||
const recencyRatio =
|
||||
recencyRange <= 0
|
||||
? 1
|
||||
: (ageSeconds - threshold.freshSeconds) / recencyRange;
|
||||
return {
|
||||
sample,
|
||||
ageSeconds,
|
||||
relevance: 'recentContext',
|
||||
confidence: clamp01(0.75 - 0.65 * recencyRatio),
|
||||
usableAsContext: true,
|
||||
canRepresentCurrentMeasurement: false,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sample,
|
||||
ageSeconds,
|
||||
relevance: 'stale',
|
||||
confidence: 0,
|
||||
usableAsContext: false,
|
||||
canRepresentCurrentMeasurement: false,
|
||||
issues: ['stale'],
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateExpectedType = (
|
||||
sample: HealthMetricSample | null,
|
||||
expectedType: HealthMetricType,
|
||||
now: Date,
|
||||
configuration: HealthContextEvaluatorConfiguration,
|
||||
): HealthMetricEvaluation | null => {
|
||||
if (sample === null) {
|
||||
return null;
|
||||
}
|
||||
if (sample.type !== expectedType) {
|
||||
return invalidEvaluation(sample, ['unexpectedMetricType']);
|
||||
}
|
||||
return evaluateHealthMetricSample(sample, now, configuration);
|
||||
};
|
||||
|
||||
const averageConfidence = (
|
||||
evaluations: readonly HealthMetricEvaluation[],
|
||||
): number => {
|
||||
const usable = evaluations.filter(evaluation => evaluation.usableAsContext);
|
||||
if (usable.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
usable.reduce((sum, evaluation) => sum + evaluation.confidence, 0) /
|
||||
usable.length
|
||||
);
|
||||
};
|
||||
|
||||
export const evaluateHealthKitContext = (
|
||||
context: HealthKitContext,
|
||||
now: Date = new Date(),
|
||||
configuration: HealthContextEvaluatorConfiguration =
|
||||
DEFAULT_HEALTH_CONTEXT_EVALUATOR_CONFIGURATION,
|
||||
): HealthContextEvaluation => {
|
||||
const issues = new Set<HealthContextIssue>();
|
||||
if (!context.available) {
|
||||
issues.add('healthKitUnavailable');
|
||||
}
|
||||
if (context.available && !context.authorizationRequestSettled) {
|
||||
issues.add('healthKitAuthorizationNotRequested');
|
||||
}
|
||||
|
||||
const latestRestingHeartRate = evaluateExpectedType(
|
||||
context.latestRestingHeartRate,
|
||||
'restingHeartRate',
|
||||
now,
|
||||
configuration,
|
||||
);
|
||||
const latestHrvSdnn = evaluateExpectedType(
|
||||
context.latestHrvSdnn,
|
||||
'hrvSdnn',
|
||||
now,
|
||||
configuration,
|
||||
);
|
||||
const recentHeartRateSamples = context.recentHeartRateSamples.map(sample =>
|
||||
evaluateExpectedType(sample, 'heartRate', now, configuration),
|
||||
).filter((evaluation): evaluation is HealthMetricEvaluation => evaluation !== null);
|
||||
|
||||
if (
|
||||
latestRestingHeartRate !== null &&
|
||||
latestRestingHeartRate.relevance === 'stale'
|
||||
) {
|
||||
issues.add('staleRestingHeartRate');
|
||||
}
|
||||
if (latestHrvSdnn !== null && latestHrvSdnn.relevance === 'stale') {
|
||||
issues.add('staleHrvSdnn');
|
||||
}
|
||||
if (
|
||||
recentHeartRateSamples.length > 0 &&
|
||||
recentHeartRateSamples.every(sample => !sample.usableAsContext)
|
||||
) {
|
||||
issues.add('staleHeartRateHistory');
|
||||
}
|
||||
|
||||
const allEvaluations = [
|
||||
...(latestRestingHeartRate === null ? [] : [latestRestingHeartRate]),
|
||||
...(latestHrvSdnn === null ? [] : [latestHrvSdnn]),
|
||||
...recentHeartRateSamples,
|
||||
];
|
||||
const usableEvaluations = allEvaluations.filter(
|
||||
evaluation => evaluation.usableAsContext,
|
||||
);
|
||||
|
||||
const weightedCategories: {weight: number; confidence: number}[] = [];
|
||||
if (latestRestingHeartRate?.usableAsContext) {
|
||||
weightedCategories.push({
|
||||
weight: 0.35,
|
||||
confidence: latestRestingHeartRate.confidence,
|
||||
});
|
||||
}
|
||||
if (latestHrvSdnn?.usableAsContext) {
|
||||
weightedCategories.push({
|
||||
weight: 0.35,
|
||||
confidence: latestHrvSdnn.confidence,
|
||||
});
|
||||
}
|
||||
if (recentHeartRateSamples.some(sample => sample.usableAsContext)) {
|
||||
weightedCategories.push({
|
||||
weight: 0.3,
|
||||
confidence: averageConfidence(recentHeartRateSamples),
|
||||
});
|
||||
}
|
||||
|
||||
const availableWeight = weightedCategories.reduce(
|
||||
(sum, category) => sum + category.weight,
|
||||
0,
|
||||
);
|
||||
const calculatedConfidence =
|
||||
availableWeight === 0
|
||||
? 0
|
||||
: weightedCategories.reduce(
|
||||
(sum, category) => sum + category.weight * category.confidence,
|
||||
0,
|
||||
) / availableWeight;
|
||||
const permissionMultiplier =
|
||||
context.available && context.authorizationRequestSettled ? 1 : 0;
|
||||
const contextConfidence =
|
||||
clamp01(calculatedConfidence) *
|
||||
clamp01(context.contextConfidence) *
|
||||
permissionMultiplier;
|
||||
|
||||
if (usableEvaluations.length === 0) {
|
||||
issues.add('noUsableHealthData');
|
||||
}
|
||||
|
||||
const sourceBundleIdentifiers = [
|
||||
...new Set(
|
||||
usableEvaluations
|
||||
.map(evaluation => evaluation.sample.sourceBundleIdentifier)
|
||||
.filter((source): source is string => source !== null),
|
||||
),
|
||||
].sort();
|
||||
|
||||
return {
|
||||
latestRestingHeartRate,
|
||||
latestHrvSdnn,
|
||||
recentHeartRateSamples,
|
||||
contextConfidence,
|
||||
usableSampleCount: usableEvaluations.length,
|
||||
sourceBundleIdentifiers,
|
||||
hasUsableContext:
|
||||
usableEvaluations.length > 0 && contextConfidence > 0,
|
||||
healthKitUsedForScore: false,
|
||||
issues: [...issues],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
haptikaHealthKit,
|
||||
type NativeHeartbeatSeriesSummary,
|
||||
type NativeHealthMetricSample,
|
||||
} from 'react-native-haptika-ppg';
|
||||
|
||||
import type {HealthKitContext, HealthMetricSample} from './healthTypes';
|
||||
|
||||
const mapSample = (sample: NativeHealthMetricSample): HealthMetricSample => ({
|
||||
type: sample.type,
|
||||
value: sample.value,
|
||||
unit: sample.unit,
|
||||
startDate: sample.startDate,
|
||||
endDate: sample.endDate,
|
||||
sourceName: sample.sourceName ?? null,
|
||||
sourceBundleIdentifier: sample.sourceBundleIdentifier ?? null,
|
||||
deviceName: sample.deviceName ?? null,
|
||||
});
|
||||
|
||||
export const healthKitService = {
|
||||
isAvailable(): boolean {
|
||||
return haptikaHealthKit.isAvailable();
|
||||
},
|
||||
|
||||
requestReadAuthorization(): Promise<boolean> {
|
||||
return haptikaHealthKit.requestReadAuthorization();
|
||||
},
|
||||
|
||||
async fetchContext(lookbackDays = 14): Promise<HealthKitContext> {
|
||||
const context = await haptikaHealthKit.fetchContext(lookbackDays);
|
||||
return {
|
||||
available: context.available,
|
||||
// The native field retains its generated ABI name, but represents only
|
||||
// HKAuthorizationRequestStatus.unnecessary — never proof of read access.
|
||||
authorizationRequestSettled: context.authorized,
|
||||
latestRestingHeartRate:
|
||||
context.latestRestingHeartRate === undefined
|
||||
? null
|
||||
: mapSample(context.latestRestingHeartRate),
|
||||
latestHrvSdnn:
|
||||
context.latestHrvSdnn === undefined
|
||||
? null
|
||||
: mapSample(context.latestHrvSdnn),
|
||||
recentHeartRateSamples: context.recentHeartRateSamples.map(mapSample),
|
||||
contextConfidence: context.contextConfidence,
|
||||
fetchedAt: context.fetchedAt,
|
||||
};
|
||||
},
|
||||
|
||||
fetchLatestHeartbeatSeries(): Promise<
|
||||
NativeHeartbeatSeriesSummary | undefined
|
||||
> {
|
||||
return haptikaHealthKit.fetchLatestHeartbeatSeries();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
export type HealthMetricType = 'heartRate' | 'restingHeartRate' | 'hrvSdnn';
|
||||
|
||||
export interface HealthMetricSample {
|
||||
type: HealthMetricType;
|
||||
value: number;
|
||||
unit: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sourceName: string | null;
|
||||
sourceBundleIdentifier: string | null;
|
||||
deviceName: string | null;
|
||||
}
|
||||
|
||||
export interface HealthKitContext {
|
||||
available: boolean;
|
||||
/** The permission sheet no longer needs presenting; Apple never reveals read approval. */
|
||||
authorizationRequestSettled: boolean;
|
||||
latestRestingHeartRate: HealthMetricSample | null;
|
||||
latestHrvSdnn: HealthMetricSample | null;
|
||||
recentHeartRateSamples: readonly HealthMetricSample[];
|
||||
contextConfidence: number;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export type HealthMetricRelevance =
|
||||
| 'freshContext'
|
||||
| 'recentContext'
|
||||
| 'stale'
|
||||
| 'futureDated'
|
||||
| 'invalid';
|
||||
|
||||
export type HealthMetricIssue =
|
||||
| 'nonFiniteValue'
|
||||
| 'nonPositiveValue'
|
||||
| 'invalidDate'
|
||||
| 'endBeforeStart'
|
||||
| 'futureDated'
|
||||
| 'stale'
|
||||
| 'unexpectedMetricType';
|
||||
|
||||
export interface HealthMetricEvaluation {
|
||||
sample: HealthMetricSample;
|
||||
ageSeconds: number | null;
|
||||
relevance: HealthMetricRelevance;
|
||||
confidence: number;
|
||||
usableAsContext: boolean;
|
||||
/** HealthKit context must never impersonate the current camera measurement. */
|
||||
canRepresentCurrentMeasurement: false;
|
||||
issues: readonly HealthMetricIssue[];
|
||||
}
|
||||
|
||||
export type HealthContextIssue =
|
||||
| 'healthKitUnavailable'
|
||||
| 'healthKitAuthorizationNotRequested'
|
||||
| 'noUsableHealthData'
|
||||
| 'staleRestingHeartRate'
|
||||
| 'staleHrvSdnn'
|
||||
| 'staleHeartRateHistory';
|
||||
|
||||
export interface HealthContextEvaluation {
|
||||
latestRestingHeartRate: HealthMetricEvaluation | null;
|
||||
latestHrvSdnn: HealthMetricEvaluation | null;
|
||||
recentHeartRateSamples: readonly HealthMetricEvaluation[];
|
||||
contextConfidence: number;
|
||||
usableSampleCount: number;
|
||||
sourceBundleIdentifiers: readonly string[];
|
||||
hasUsableContext: boolean;
|
||||
healthKitUsedForScore: false;
|
||||
issues: readonly HealthContextIssue[];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {StoredMeasurement} from '../storage/storageTypes';
|
||||
|
||||
export interface DailyTrendPoint {
|
||||
day: string;
|
||||
bpm: number | null;
|
||||
rmssdMs: number | null;
|
||||
quality: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const average = (values: readonly number[]): number | null =>
|
||||
values.length === 0
|
||||
? null
|
||||
: values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
|
||||
export function buildDailyTrends(
|
||||
measurements: readonly StoredMeasurement[],
|
||||
days = 14,
|
||||
now = Date.now(),
|
||||
): DailyTrendPoint[] {
|
||||
const cutoff = now - days * 86_400_000;
|
||||
const groups = new Map<string, StoredMeasurement[]>();
|
||||
measurements
|
||||
.filter(measurement =>
|
||||
measurement.cameraResult.valid && Date.parse(measurement.measuredAt) >= cutoff,
|
||||
)
|
||||
.forEach(measurement => {
|
||||
const day = measurement.measuredAt.slice(0, 10);
|
||||
groups.set(day, [...(groups.get(day) ?? []), measurement]);
|
||||
});
|
||||
return [...groups.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([day, values]) => {
|
||||
const bpm = values
|
||||
.map(value => value.cameraResult.bpm.bpm)
|
||||
.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
const rmssd = values
|
||||
.map(value => value.cameraResult.prv.estimatedRmssdMs)
|
||||
.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
return {
|
||||
day,
|
||||
bpm: average(bpm),
|
||||
rmssdMs: average(rmssd),
|
||||
quality: average(values.map(value => value.cameraResult.quality.score)) ?? 0,
|
||||
count: values.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface FeelingCorrelationPoint {
|
||||
feeling: number;
|
||||
averageBpm: number | null;
|
||||
averageRmssdMs: number | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function buildFeelingCorrelation(
|
||||
measurements: readonly StoredMeasurement[],
|
||||
): FeelingCorrelationPoint[] {
|
||||
return [1, 2, 3, 4, 5].flatMap(feeling => {
|
||||
const matches = measurements.filter(
|
||||
measurement => measurement.selfReport?.feeling === feeling,
|
||||
);
|
||||
if (matches.length === 0) return [];
|
||||
const bpm = matches
|
||||
.map(value => value.cameraResult.bpm.bpm)
|
||||
.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
const rmssd = matches
|
||||
.map(value => value.cameraResult.prv.estimatedRmssdMs)
|
||||
.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
return [{
|
||||
feeling,
|
||||
averageBpm: average(bpm),
|
||||
averageRmssdMs: average(rmssd),
|
||||
count: matches.length,
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type {PPGProcessingResult} from '../measurement/measurementTypes';
|
||||
import type {StoredMeasurement} from '../storage/storageTypes';
|
||||
|
||||
export type MetricInsightKey =
|
||||
| 'bpm'
|
||||
| 'meanIpi'
|
||||
| 'rmssd'
|
||||
| 'sdnn'
|
||||
| 'pnn50'
|
||||
| 'rangeIpi'
|
||||
| 'modeIpi'
|
||||
| 'amo50'
|
||||
| 'cv';
|
||||
|
||||
export type MetricInsightTone = 'good' | 'neutral' | 'attention' | 'unavailable';
|
||||
|
||||
export interface MetricTrendPoint {
|
||||
id: string;
|
||||
measuredAt: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MetricInsight {
|
||||
key: MetricInsightKey;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
value: number | null;
|
||||
unit: string;
|
||||
digits: number;
|
||||
status: string;
|
||||
tone: MetricInsightTone;
|
||||
description: string;
|
||||
methodology: string;
|
||||
trend: readonly MetricTrendPoint[];
|
||||
referenceRange: {minimum: number; maximum: number} | null;
|
||||
}
|
||||
|
||||
interface MetricDefinition {
|
||||
key: MetricInsightKey;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
unit: string;
|
||||
digits: number;
|
||||
description: string;
|
||||
methodology: string;
|
||||
value: (result: PPGProcessingResult) => number | null;
|
||||
requiresReliablePrv?: boolean;
|
||||
}
|
||||
|
||||
const finiteOrNull = (value: number | null | undefined): number | null =>
|
||||
value !== null && value !== undefined && Number.isFinite(value) ? value : null;
|
||||
|
||||
const definitions: readonly MetricDefinition[] = [
|
||||
{
|
||||
key: 'bpm',
|
||||
label: 'Pouls moyen',
|
||||
shortLabel: 'Pouls',
|
||||
unit: 'BPM',
|
||||
digits: 0,
|
||||
description:
|
||||
'Le nombre moyen de pulsations détectées par minute pendant cette mesure.',
|
||||
methodology: 'PPG caméra iPhone',
|
||||
value: result => finiteOrNull(result.bpm.bpm),
|
||||
},
|
||||
{
|
||||
key: 'meanIpi',
|
||||
label: 'Intervalle pulsatile moyen',
|
||||
shortLabel: 'RR moyen',
|
||||
unit: 'ms',
|
||||
digits: 0,
|
||||
description:
|
||||
'Le temps moyen entre deux pulsations détectées. Il évolue à l’inverse du pouls.',
|
||||
methodology: 'Moyenne des IPI valides',
|
||||
value: result => finiteOrNull(result.prv.meanIpiMs),
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
{
|
||||
key: 'rmssd',
|
||||
label: 'Variabilité pulsatile courte',
|
||||
shortLabel: 'RMSSD',
|
||||
unit: 'ms',
|
||||
digits: 0,
|
||||
description:
|
||||
'Décrit les variations rapides entre intervalles successifs. La comparaison la plus utile est avec vos propres mesures au repos.',
|
||||
methodology: 'PRV caméra expérimentale · pas une HRV ECG',
|
||||
value: result => finiteOrNull(result.prv.estimatedRmssdMs),
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
{
|
||||
key: 'sdnn',
|
||||
label: 'Variabilité pulsatile globale',
|
||||
shortLabel: 'SDNN',
|
||||
unit: 'ms',
|
||||
digits: 0,
|
||||
description:
|
||||
'Résume la dispersion des intervalles valides sur toute la durée de la mesure.',
|
||||
methodology: 'PRV caméra expérimentale · pas une HRV ECG',
|
||||
value: result => finiteOrNull(result.prv.estimatedSdnnMs),
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
{
|
||||
key: 'pnn50',
|
||||
label: 'Intervalles très variables',
|
||||
shortLabel: 'pNN50',
|
||||
unit: '%',
|
||||
digits: 0,
|
||||
description:
|
||||
'Part des intervalles successifs dont l’écart dépasse 50 ms. Cette valeur dépend fortement de la qualité des pics.',
|
||||
methodology: 'Calcul sur les IPI valides',
|
||||
value: result => finiteOrNull(result.prv.pnn50Percent),
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
{
|
||||
key: 'rangeIpi',
|
||||
label: 'Étendue des intervalles',
|
||||
shortLabel: 'MxDMn',
|
||||
unit: 's',
|
||||
digits: 2,
|
||||
description:
|
||||
'Écart entre l’intervalle le plus long et le plus court retenus pendant la mesure.',
|
||||
methodology: 'Maximum moins minimum des IPI valides',
|
||||
value: result => {
|
||||
const value = finiteOrNull(result.prv.rangeIpiMs);
|
||||
return value === null ? null : value / 1_000;
|
||||
},
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
{
|
||||
key: 'modeIpi',
|
||||
label: 'Intervalle le plus fréquent',
|
||||
shortLabel: 'Moda',
|
||||
unit: 'ms',
|
||||
digits: 0,
|
||||
description:
|
||||
'La zone d’intervalle la plus souvent observée parmi les pulsations retenues.',
|
||||
methodology: 'Mode des IPI par classes de 50 ms',
|
||||
value: result => finiteOrNull(result.prv.modeIpiMs),
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
{
|
||||
key: 'amo50',
|
||||
label: 'Concentration autour du mode',
|
||||
shortLabel: 'AMo50',
|
||||
unit: '%',
|
||||
digits: 0,
|
||||
description:
|
||||
'Part des intervalles regroupés autour de la valeur la plus fréquente.',
|
||||
methodology: 'Amplitude du mode par classes de 50 ms',
|
||||
value: result => finiteOrNull(result.prv.amo50Percent),
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
{
|
||||
key: 'cv',
|
||||
label: 'Dispersion relative',
|
||||
shortLabel: 'CV',
|
||||
unit: '%',
|
||||
digits: 1,
|
||||
description:
|
||||
'Rapporte la dispersion des intervalles à leur moyenne pour faciliter les comparaisons entre mesures.',
|
||||
methodology: 'Coefficient de variation des IPI valides',
|
||||
value: result => {
|
||||
const value = finiteOrNull(result.prv.coefficientOfVariation);
|
||||
return value === null ? null : value * 100;
|
||||
},
|
||||
requiresReliablePrv: true,
|
||||
},
|
||||
];
|
||||
|
||||
const median = (values: readonly number[]): number => {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1]! + sorted[middle]!) / 2
|
||||
: sorted[middle]!;
|
||||
};
|
||||
|
||||
const personalRange = (
|
||||
values: readonly number[],
|
||||
): {minimum: number; maximum: number} | null => {
|
||||
if (values.length < 5) return null;
|
||||
const center = median(values);
|
||||
const mad = median(values.map(value => Math.abs(value - center)));
|
||||
const halfWidth = Math.max(mad * 2.2239, Math.abs(center) * 0.04, 0.5);
|
||||
return {minimum: center - halfWidth, maximum: center + halfWidth};
|
||||
};
|
||||
|
||||
const interpretation = (
|
||||
value: number | null,
|
||||
range: {minimum: number; maximum: number} | null,
|
||||
): Pick<MetricInsight, 'status' | 'tone'> => {
|
||||
if (value === null) {
|
||||
return {status: 'Signal insuffisant', tone: 'unavailable'};
|
||||
}
|
||||
if (range === null) {
|
||||
return {status: 'Repère en construction', tone: 'neutral'};
|
||||
}
|
||||
if (value < range.minimum) {
|
||||
return {status: 'Sous votre repère habituel', tone: 'attention'};
|
||||
}
|
||||
if (value > range.maximum) {
|
||||
return {status: 'Au-dessus de votre repère habituel', tone: 'attention'};
|
||||
}
|
||||
return {status: 'Dans votre repère habituel', tone: 'good'};
|
||||
};
|
||||
|
||||
export const buildMetricInsights = (
|
||||
currentResult: PPGProcessingResult,
|
||||
measurements: readonly StoredMeasurement[],
|
||||
): readonly MetricInsight[] =>
|
||||
definitions.map(definition => {
|
||||
const currentValue =
|
||||
definition.requiresReliablePrv && !currentResult.prv.reliable
|
||||
? null
|
||||
: definition.value(currentResult);
|
||||
const trend = measurements
|
||||
.filter(
|
||||
measurement =>
|
||||
!definition.requiresReliablePrv || measurement.cameraResult.prv.reliable,
|
||||
)
|
||||
.map(measurement => ({
|
||||
id: measurement.id,
|
||||
measuredAt: measurement.measuredAt,
|
||||
value: definition.value(measurement.cameraResult),
|
||||
}))
|
||||
.filter(
|
||||
(point): point is MetricTrendPoint =>
|
||||
point.value !== null && Number.isFinite(point.value),
|
||||
)
|
||||
.sort(
|
||||
(left, right) => Date.parse(left.measuredAt) - Date.parse(right.measuredAt),
|
||||
)
|
||||
.slice(-30);
|
||||
const comparableValues = trend.map(point => point.value);
|
||||
const referenceRange = personalRange(comparableValues);
|
||||
const {status, tone} = interpretation(currentValue, referenceRange);
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
shortLabel: definition.shortLabel,
|
||||
value: currentValue,
|
||||
unit: definition.unit,
|
||||
digits: definition.digits,
|
||||
status,
|
||||
tone,
|
||||
description: definition.description,
|
||||
methodology: definition.methodology,
|
||||
trend,
|
||||
referenceRange,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
export const PPG_ALGORITHM_VERSION = 'HAPTIKA_PPG_FRONT_V1.0.0';
|
||||
|
||||
/**
|
||||
* Publication gates shared conceptually with native `PPGPublicationThresholds`.
|
||||
* BPM is a mean-rate output, PRV requires stricter beat timing, and the potential
|
||||
* index remains a separate non-medical product calculation based on a baseline.
|
||||
*/
|
||||
export const PPG_PUBLICATION_THRESHOLDS = Object.freeze({
|
||||
bpmQuality: 0.45,
|
||||
bpmConfidence: 0.4,
|
||||
prvQuality: 0.8,
|
||||
prvBpmConfidence: 0.7,
|
||||
potentialIndexQuality: 0.65,
|
||||
potentialIndexMinimumIntervals: 20,
|
||||
});
|
||||
|
||||
export interface MeasurementConfiguration {
|
||||
targetSampleRate: number;
|
||||
minimumFrequencyHz: number;
|
||||
maximumFrequencyHz: number;
|
||||
stabilizationSeconds: number;
|
||||
minimumMeasurementSeconds: number;
|
||||
measurementSeconds: number;
|
||||
targetValidBeatCount: number;
|
||||
emitEveryFrames: number;
|
||||
minimumContactConfidence: number;
|
||||
minimumSignalQuality: number;
|
||||
goodSignalQuality: number;
|
||||
maximumShortGapSeconds: number;
|
||||
maximumDroppedFrameRatio: number;
|
||||
maximumRejectedIntervalRatio: number;
|
||||
minimumReliableIntervals: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MEASUREMENT_CONFIGURATION: Readonly<MeasurementConfiguration> =
|
||||
Object.freeze({
|
||||
// Keep the processing timebase equal to the requested camera cadence.
|
||||
// Interpolation cannot create optical information that the sensor did not
|
||||
// capture; sub-frame peak timing is handled by native peak refinement.
|
||||
targetSampleRate: 30,
|
||||
minimumFrequencyHz: 0.7,
|
||||
maximumFrequencyHz: 3.5,
|
||||
stabilizationSeconds: 5,
|
||||
minimumMeasurementSeconds: 45,
|
||||
measurementSeconds: 120,
|
||||
targetValidBeatCount: 45,
|
||||
emitEveryFrames: 3,
|
||||
minimumContactConfidence: 0.48,
|
||||
minimumSignalQuality: PPG_PUBLICATION_THRESHOLDS.potentialIndexQuality,
|
||||
goodSignalQuality: 0.8,
|
||||
maximumShortGapSeconds: 0.25,
|
||||
maximumDroppedFrameRatio: 0.1,
|
||||
maximumRejectedIntervalRatio: 0.25,
|
||||
minimumReliableIntervals:
|
||||
PPG_PUBLICATION_THRESHOLDS.potentialIndexMinimumIntervals,
|
||||
});
|
||||
|
||||
const assertFinite = (name: string, value: number): void => {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new RangeError(`${name} must be finite.`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertRatio = (name: string, value: number): void => {
|
||||
assertFinite(name, value);
|
||||
if (value < 0 || value > 1) {
|
||||
throw new RangeError(`${name} must be between 0 and 1.`);
|
||||
}
|
||||
};
|
||||
|
||||
export const validateMeasurementConfiguration = (
|
||||
configuration: MeasurementConfiguration,
|
||||
): MeasurementConfiguration => {
|
||||
assertFinite('targetSampleRate', configuration.targetSampleRate);
|
||||
if (
|
||||
configuration.targetSampleRate < 20 ||
|
||||
configuration.targetSampleRate > 120
|
||||
) {
|
||||
throw new RangeError('targetSampleRate must be between 20 and 120 Hz.');
|
||||
}
|
||||
|
||||
assertFinite('minimumFrequencyHz', configuration.minimumFrequencyHz);
|
||||
assertFinite('maximumFrequencyHz', configuration.maximumFrequencyHz);
|
||||
if (
|
||||
configuration.minimumFrequencyHz <= 0 ||
|
||||
configuration.maximumFrequencyHz <= configuration.minimumFrequencyHz ||
|
||||
configuration.maximumFrequencyHz >= configuration.targetSampleRate / 2
|
||||
) {
|
||||
throw new RangeError(
|
||||
'The physiological band must be positive, ordered, and below Nyquist.',
|
||||
);
|
||||
}
|
||||
|
||||
assertFinite('stabilizationSeconds', configuration.stabilizationSeconds);
|
||||
if (
|
||||
configuration.stabilizationSeconds < 0 ||
|
||||
configuration.stabilizationSeconds > 30
|
||||
) {
|
||||
throw new RangeError('stabilizationSeconds must be between 0 and 30.');
|
||||
}
|
||||
|
||||
assertFinite('measurementSeconds', configuration.measurementSeconds);
|
||||
if (
|
||||
configuration.measurementSeconds < 20 ||
|
||||
configuration.measurementSeconds > 120
|
||||
) {
|
||||
throw new RangeError('measurementSeconds must be between 20 and 120.');
|
||||
}
|
||||
|
||||
assertFinite(
|
||||
'minimumMeasurementSeconds',
|
||||
configuration.minimumMeasurementSeconds,
|
||||
);
|
||||
if (
|
||||
configuration.minimumMeasurementSeconds < 20 ||
|
||||
configuration.minimumMeasurementSeconds > configuration.measurementSeconds
|
||||
) {
|
||||
throw new RangeError(
|
||||
'minimumMeasurementSeconds must be between 20 and measurementSeconds.',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(configuration.targetValidBeatCount) ||
|
||||
configuration.targetValidBeatCount < 20 ||
|
||||
configuration.targetValidBeatCount > 300
|
||||
) {
|
||||
throw new RangeError(
|
||||
'targetValidBeatCount must be an integer between 20 and 300.',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(configuration.emitEveryFrames) ||
|
||||
configuration.emitEveryFrames < 1
|
||||
) {
|
||||
throw new RangeError('emitEveryFrames must be a positive integer.');
|
||||
}
|
||||
|
||||
assertRatio(
|
||||
'minimumContactConfidence',
|
||||
configuration.minimumContactConfidence,
|
||||
);
|
||||
assertRatio('minimumSignalQuality', configuration.minimumSignalQuality);
|
||||
assertRatio('goodSignalQuality', configuration.goodSignalQuality);
|
||||
if (configuration.goodSignalQuality < configuration.minimumSignalQuality) {
|
||||
throw new RangeError(
|
||||
'goodSignalQuality must be greater than or equal to minimumSignalQuality.',
|
||||
);
|
||||
}
|
||||
|
||||
assertFinite(
|
||||
'maximumShortGapSeconds',
|
||||
configuration.maximumShortGapSeconds,
|
||||
);
|
||||
if (
|
||||
configuration.maximumShortGapSeconds <= 0 ||
|
||||
configuration.maximumShortGapSeconds > 1
|
||||
) {
|
||||
throw new RangeError('maximumShortGapSeconds must be in (0, 1].');
|
||||
}
|
||||
|
||||
assertRatio(
|
||||
'maximumDroppedFrameRatio',
|
||||
configuration.maximumDroppedFrameRatio,
|
||||
);
|
||||
assertRatio(
|
||||
'maximumRejectedIntervalRatio',
|
||||
configuration.maximumRejectedIntervalRatio,
|
||||
);
|
||||
|
||||
if (
|
||||
!Number.isInteger(configuration.minimumReliableIntervals) ||
|
||||
configuration.minimumReliableIntervals < 3
|
||||
) {
|
||||
throw new RangeError(
|
||||
'minimumReliableIntervals must be an integer greater than or equal to 3.',
|
||||
);
|
||||
}
|
||||
|
||||
return configuration;
|
||||
};
|
||||
|
||||
export const createMeasurementConfiguration = (
|
||||
overrides: Partial<MeasurementConfiguration> = {},
|
||||
): Readonly<MeasurementConfiguration> => {
|
||||
const configuration: MeasurementConfiguration = {
|
||||
...DEFAULT_MEASUREMENT_CONFIGURATION,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
validateMeasurementConfiguration(configuration);
|
||||
return Object.freeze(configuration);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {NativePPGProcessingResult} from 'react-native-haptika-ppg';
|
||||
|
||||
import type {
|
||||
PPGProcessingResult,
|
||||
SignalQualityIssue,
|
||||
SignalQualityLevel,
|
||||
} from './measurementTypes';
|
||||
|
||||
const KNOWN_ISSUES: ReadonlySet<SignalQualityIssue> = new Set([
|
||||
'contactInsufficient',
|
||||
'contactInterrupted',
|
||||
'durationInsufficient',
|
||||
'droppedFrames',
|
||||
'timestampJitter',
|
||||
'longGap',
|
||||
'saturation',
|
||||
'motionArtifacts',
|
||||
'lowPulsatileAmplitude',
|
||||
'lowSignalToNoiseRatio',
|
||||
'tooFewPeaks',
|
||||
'tooManyRejectedPeaks',
|
||||
'tooManyRejectedIntervals',
|
||||
'methodDisagreement',
|
||||
'channelInstability',
|
||||
'nonFiniteProcessingResult',
|
||||
]);
|
||||
|
||||
const finiteOrNull = (value: number | undefined): number | null =>
|
||||
value !== undefined && Number.isFinite(value) ? value : null;
|
||||
|
||||
const qualityLevel = (level: string): SignalQualityLevel => {
|
||||
if (level === 'good' || level === 'medium' || level === 'poor') {
|
||||
return level;
|
||||
}
|
||||
return 'invalid';
|
||||
};
|
||||
|
||||
const qualityIssue = (issue: string): SignalQualityIssue =>
|
||||
KNOWN_ISSUES.has(issue as SignalQualityIssue)
|
||||
? (issue as SignalQualityIssue)
|
||||
: 'nonFiniteProcessingResult';
|
||||
|
||||
export function mapNativeProcessingResult(
|
||||
result: NativePPGProcessingResult,
|
||||
): PPGProcessingResult {
|
||||
return {
|
||||
valid: result.valid,
|
||||
invalidReason: result.invalidReason ?? null,
|
||||
bpm: {
|
||||
bpm: finiteOrNull(result.bpm.bpm),
|
||||
temporalBpm: finiteOrNull(result.bpm.temporalBpm),
|
||||
frequencyBpm: finiteOrNull(result.bpm.frequencyBpm),
|
||||
methodAgreementBpm: finiteOrNull(result.bpm.methodAgreementBpm),
|
||||
validPeakCount: Math.max(0, Math.round(result.bpm.validPeakCount)),
|
||||
rejectedPeakCount: Math.max(0, Math.round(result.bpm.rejectedPeakCount)),
|
||||
confidence: result.bpm.confidence,
|
||||
selectedChannel: result.bpm.selectedChannel,
|
||||
},
|
||||
prv: {
|
||||
estimatedRmssdMs: finiteOrNull(result.prv.estimatedRmssdMs),
|
||||
estimatedSdnnMs: finiteOrNull(result.prv.estimatedSdnnMs),
|
||||
meanIpiMs: finiteOrNull(result.prv.meanIpiMs),
|
||||
medianIpiMs: finiteOrNull(result.prv.medianIpiMs),
|
||||
pnn50Percent: finiteOrNull(result.prv.pnn50Percent),
|
||||
rangeIpiMs: finiteOrNull(result.prv.rangeIpiMs),
|
||||
modeIpiMs: finiteOrNull(result.prv.modeIpiMs),
|
||||
amo50Percent: finiteOrNull(result.prv.amo50Percent),
|
||||
coefficientOfVariation: finiteOrNull(
|
||||
result.prv.coefficientOfVariation,
|
||||
),
|
||||
validIntervalCount: Math.max(
|
||||
0,
|
||||
Math.round(result.prv.validIntervalCount),
|
||||
),
|
||||
rejectedIntervalCount: Math.max(
|
||||
0,
|
||||
Math.round(result.prv.rejectedIntervalCount),
|
||||
),
|
||||
rejectedIntervalRatio: result.prv.rejectedIntervalRatio,
|
||||
reliable: result.prv.reliable,
|
||||
},
|
||||
quality: {
|
||||
score: result.quality.score,
|
||||
level: qualityLevel(result.quality.level),
|
||||
usableDurationSeconds: result.quality.usableDurationSeconds,
|
||||
droppedFrameRatio: result.quality.droppedFrameRatio,
|
||||
saturationRatio: result.quality.saturationRatio,
|
||||
rejectedIntervalRatio: result.quality.rejectedIntervalRatio,
|
||||
signalToNoiseRatio: finiteOrNull(result.quality.signalToNoiseRatio),
|
||||
motionArtifactRatio: result.quality.motionArtifactRatio,
|
||||
methodAgreementScore: result.quality.methodAgreementScore,
|
||||
timestampJitterRatio: result.quality.timestampJitterRatio,
|
||||
longestGapSeconds: result.quality.longestGapSeconds,
|
||||
issues: result.quality.issues.map(qualityIssue),
|
||||
},
|
||||
startedAtMonotonicSeconds: result.startedAtMonotonicSeconds,
|
||||
endedAtMonotonicSeconds: result.endedAtMonotonicSeconds,
|
||||
rawSampleCount: Math.max(0, Math.round(result.rawSampleCount)),
|
||||
resampledSampleCount: Math.max(
|
||||
0,
|
||||
Math.round(result.resampledSampleCount),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import type {
|
||||
MeasurementEvent,
|
||||
MeasurementFailure,
|
||||
MeasurementMachineContext,
|
||||
MeasurementMachineSnapshot,
|
||||
MeasurementState,
|
||||
} from './measurementTypes';
|
||||
|
||||
const ACTIVE_STATES: ReadonlySet<MeasurementState> = new Set([
|
||||
'requestingCameraPermission',
|
||||
'requestingHealthPermission',
|
||||
'preparingCamera',
|
||||
'waitingForFinger',
|
||||
'stabilizing',
|
||||
'measuring',
|
||||
'processing',
|
||||
]);
|
||||
|
||||
const RETRYABLE_STATES: ReadonlySet<MeasurementState> = new Set([
|
||||
'invalid',
|
||||
'cancelled',
|
||||
'thermalStop',
|
||||
'interrupted',
|
||||
'error',
|
||||
]);
|
||||
|
||||
const initialContext = (): MeasurementMachineContext => ({
|
||||
attempt: 0,
|
||||
shouldRequestHealthKit: false,
|
||||
cameraPermission: 'unknown',
|
||||
healthPermission: 'notRequested',
|
||||
contactState: 'absent',
|
||||
thermalState: 'unknown',
|
||||
failure: null,
|
||||
result: null,
|
||||
});
|
||||
|
||||
export const createInitialMeasurementMachine = (): MeasurementMachineSnapshot =>
|
||||
({
|
||||
value: 'idle',
|
||||
context: initialContext(),
|
||||
});
|
||||
|
||||
const withContext = (
|
||||
snapshot: MeasurementMachineSnapshot,
|
||||
context: Partial<MeasurementMachineContext>,
|
||||
value: MeasurementState = snapshot.value,
|
||||
): MeasurementMachineSnapshot => ({
|
||||
value,
|
||||
context: {...snapshot.context, ...context},
|
||||
});
|
||||
|
||||
const isUnsafeThermalState = (
|
||||
thermalState: MeasurementMachineContext['thermalState'],
|
||||
): thermalState is 'serious' | 'critical' =>
|
||||
thermalState === 'serious' || thermalState === 'critical';
|
||||
|
||||
const thermalFailure = (
|
||||
thermalState: Extract<
|
||||
MeasurementMachineContext['thermalState'],
|
||||
'serious' | 'critical'
|
||||
>,
|
||||
): MeasurementFailure => ({
|
||||
code: thermalState === 'critical' ? 'thermalCritical' : 'thermalSerious',
|
||||
message:
|
||||
thermalState === 'critical'
|
||||
? 'L’iPhone a atteint un état thermique critique.'
|
||||
: 'L’iPhone est trop chaud pour poursuivre la mesure en sécurité.',
|
||||
recoverable: true,
|
||||
});
|
||||
|
||||
const interruptFailure = (reason: string): MeasurementFailure => ({
|
||||
code: 'applicationInterrupted',
|
||||
message: reason,
|
||||
recoverable: true,
|
||||
});
|
||||
|
||||
const retry = (
|
||||
snapshot: MeasurementMachineSnapshot,
|
||||
): MeasurementMachineSnapshot => {
|
||||
const canReuseCameraPermission =
|
||||
snapshot.context.cameraPermission === 'granted';
|
||||
|
||||
if (!canReuseCameraPermission) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
contactState: 'absent',
|
||||
failure: null,
|
||||
result: null,
|
||||
},
|
||||
'idle',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
value: 'preparingCamera',
|
||||
context: {
|
||||
...snapshot.context,
|
||||
attempt: snapshot.context.attempt + 1,
|
||||
cameraPermission: 'granted',
|
||||
contactState: 'absent',
|
||||
failure: null,
|
||||
result: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure reducer for the product measurement lifecycle. Camera resources and
|
||||
* timers remain coordinator responsibilities; this reducer only makes legal
|
||||
* state transitions explicit and deterministic.
|
||||
*/
|
||||
export const transitionMeasurement = (
|
||||
snapshot: MeasurementMachineSnapshot,
|
||||
event: MeasurementEvent,
|
||||
): MeasurementMachineSnapshot => {
|
||||
if (event.type === 'RESET') {
|
||||
if (isUnsafeThermalState(snapshot.context.thermalState)) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{failure: thermalFailure(snapshot.context.thermalState), result: null},
|
||||
'thermalStop',
|
||||
);
|
||||
}
|
||||
const resetSnapshot = createInitialMeasurementMachine();
|
||||
return withContext(resetSnapshot, {
|
||||
thermalState: snapshot.context.thermalState,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === 'THERMAL_STATE_CHANGED') {
|
||||
if (isUnsafeThermalState(event.thermalState) && ACTIVE_STATES.has(snapshot.value)) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
thermalState: event.thermalState,
|
||||
failure: thermalFailure(event.thermalState),
|
||||
result: null,
|
||||
},
|
||||
'thermalStop',
|
||||
);
|
||||
}
|
||||
return withContext(snapshot, {thermalState: event.thermalState});
|
||||
}
|
||||
|
||||
if (event.type === 'CANCEL' && ACTIVE_STATES.has(snapshot.value)) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{contactState: 'absent', failure: null, result: null},
|
||||
'cancelled',
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'INTERRUPTED' && ACTIVE_STATES.has(snapshot.value)) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
contactState: 'absent',
|
||||
failure: interruptFailure(event.reason),
|
||||
result: null,
|
||||
},
|
||||
'interrupted',
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'FAIL' && snapshot.value !== 'completed') {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{failure: event.failure, result: null},
|
||||
'error',
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'RETRY' && RETRYABLE_STATES.has(snapshot.value)) {
|
||||
if (isUnsafeThermalState(snapshot.context.thermalState)) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
failure: thermalFailure(snapshot.context.thermalState),
|
||||
},
|
||||
'thermalStop',
|
||||
);
|
||||
}
|
||||
return retry(snapshot);
|
||||
}
|
||||
|
||||
switch (snapshot.value) {
|
||||
case 'idle': {
|
||||
if (event.type !== 'START') {
|
||||
return snapshot;
|
||||
}
|
||||
if (isUnsafeThermalState(snapshot.context.thermalState)) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
failure: thermalFailure(snapshot.context.thermalState),
|
||||
},
|
||||
'thermalStop',
|
||||
);
|
||||
}
|
||||
return {
|
||||
value: 'requestingCameraPermission',
|
||||
context: {
|
||||
...initialContext(),
|
||||
attempt: snapshot.context.attempt + 1,
|
||||
shouldRequestHealthKit: event.requestHealthKit ?? false,
|
||||
cameraPermission: 'requesting',
|
||||
thermalState: snapshot.context.thermalState,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'requestingCameraPermission': {
|
||||
if (event.type === 'CAMERA_PERMISSION_GRANTED') {
|
||||
const shouldRequestHealthKit =
|
||||
snapshot.context.shouldRequestHealthKit;
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
cameraPermission: 'granted',
|
||||
healthPermission: shouldRequestHealthKit
|
||||
? 'requesting'
|
||||
: 'notRequested',
|
||||
},
|
||||
shouldRequestHealthKit
|
||||
? 'requestingHealthPermission'
|
||||
: 'preparingCamera',
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'CAMERA_PERMISSION_DENIED') {
|
||||
const restricted = event.restricted ?? false;
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
cameraPermission: restricted ? 'restricted' : 'denied',
|
||||
failure: {
|
||||
code: restricted
|
||||
? 'cameraRestricted'
|
||||
: 'cameraPermissionDenied',
|
||||
message: restricted
|
||||
? 'L’accès à la caméra est restreint sur cet iPhone.'
|
||||
: 'L’accès à la caméra a été refusé.',
|
||||
recoverable: !restricted,
|
||||
},
|
||||
},
|
||||
'error',
|
||||
);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case 'requestingHealthPermission': {
|
||||
if (event.type !== 'HEALTH_PERMISSION_RESOLVED') {
|
||||
return snapshot;
|
||||
}
|
||||
// HealthKit is optional: a failed request or unavailable service never
|
||||
// blocks camera PPG. Read access itself is intentionally not inferred.
|
||||
return withContext(
|
||||
snapshot,
|
||||
{healthPermission: event.status},
|
||||
'preparingCamera',
|
||||
);
|
||||
}
|
||||
|
||||
case 'preparingCamera': {
|
||||
if (event.type === 'CAMERA_READY') {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{contactState: 'adjusting', failure: null},
|
||||
'stabilizing',
|
||||
);
|
||||
}
|
||||
if (event.type === 'CAMERA_PREPARATION_FAILED') {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{failure: event.failure},
|
||||
'error',
|
||||
);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case 'waitingForFinger': {
|
||||
if (event.type !== 'CONTACT_CHANGED') {
|
||||
return snapshot;
|
||||
}
|
||||
return withContext(
|
||||
snapshot,
|
||||
{contactState: event.contactState},
|
||||
event.contactState === 'valid' ? 'stabilizing' : 'waitingForFinger',
|
||||
);
|
||||
}
|
||||
|
||||
case 'stabilizing': {
|
||||
if (event.type === 'CONTACT_CHANGED') {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{contactState: event.contactState},
|
||||
'stabilizing',
|
||||
);
|
||||
}
|
||||
if (event.type === 'STABILIZATION_COMPLETE') {
|
||||
return withContext(snapshot, {}, 'measuring');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case 'measuring': {
|
||||
if (event.type === 'CONTACT_CHANGED') {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{contactState: event.contactState},
|
||||
'measuring',
|
||||
);
|
||||
}
|
||||
if (event.type === 'ACQUISITION_COMPLETE') {
|
||||
return withContext(snapshot, {}, 'processing');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case 'processing': {
|
||||
if (event.type === 'PROCESSING_SUCCEEDED') {
|
||||
if (
|
||||
!event.result.valid ||
|
||||
event.result.quality.level === 'invalid'
|
||||
) {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
result: event.result,
|
||||
failure: {
|
||||
code: 'poorSignal',
|
||||
message:
|
||||
event.result.invalidReason ??
|
||||
'La qualité du signal est insuffisante.',
|
||||
recoverable: true,
|
||||
},
|
||||
},
|
||||
'invalid',
|
||||
);
|
||||
}
|
||||
return withContext(
|
||||
snapshot,
|
||||
{result: event.result, failure: null},
|
||||
'completed',
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === 'PROCESSING_INVALID') {
|
||||
return withContext(
|
||||
snapshot,
|
||||
{
|
||||
result: event.result ?? null,
|
||||
failure: {
|
||||
code: 'poorSignal',
|
||||
message: event.reason,
|
||||
recoverable: true,
|
||||
},
|
||||
},
|
||||
'invalid',
|
||||
);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case 'completed':
|
||||
case 'invalid':
|
||||
case 'cancelled':
|
||||
case 'thermalStop':
|
||||
case 'interrupted':
|
||||
case 'error':
|
||||
return snapshot;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
export type MeasurementState =
|
||||
| 'idle'
|
||||
| 'requestingCameraPermission'
|
||||
| 'requestingHealthPermission'
|
||||
| 'preparingCamera'
|
||||
| 'waitingForFinger'
|
||||
| 'stabilizing'
|
||||
| 'measuring'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'invalid'
|
||||
| 'cancelled'
|
||||
| 'thermalStop'
|
||||
| 'interrupted'
|
||||
| 'error';
|
||||
|
||||
export type FingerContactState =
|
||||
| 'absent'
|
||||
| 'adjusting'
|
||||
| 'valid'
|
||||
| 'tooMuchPressure'
|
||||
| 'overexposed'
|
||||
| 'unstable';
|
||||
|
||||
export type ThermalState =
|
||||
| 'nominal'
|
||||
| 'fair'
|
||||
| 'serious'
|
||||
| 'critical'
|
||||
| 'unknown';
|
||||
|
||||
export type CameraPermissionStatus =
|
||||
| 'unknown'
|
||||
| 'requesting'
|
||||
| 'granted'
|
||||
| 'denied'
|
||||
| 'restricted';
|
||||
|
||||
export type HealthPermissionStatus =
|
||||
| 'notRequested'
|
||||
| 'requesting'
|
||||
| 'requestCompleted'
|
||||
| 'requestFailed'
|
||||
| 'unavailable';
|
||||
|
||||
export type SignalQualityLevel = 'good' | 'medium' | 'poor' | 'invalid';
|
||||
|
||||
export type SignalQualityIssue =
|
||||
| 'contactInsufficient'
|
||||
| 'contactInterrupted'
|
||||
| 'durationInsufficient'
|
||||
| 'droppedFrames'
|
||||
| 'timestampJitter'
|
||||
| 'longGap'
|
||||
| 'saturation'
|
||||
| 'motionArtifacts'
|
||||
| 'lowPulsatileAmplitude'
|
||||
| 'lowSignalToNoiseRatio'
|
||||
| 'tooFewPeaks'
|
||||
| 'tooManyRejectedPeaks'
|
||||
| 'tooManyRejectedIntervals'
|
||||
| 'methodDisagreement'
|
||||
| 'channelInstability'
|
||||
| 'nonFiniteProcessingResult';
|
||||
|
||||
export interface BPMEstimate {
|
||||
bpm: number | null;
|
||||
temporalBpm: number | null;
|
||||
frequencyBpm: number | null;
|
||||
methodAgreementBpm: number | null;
|
||||
validPeakCount: number;
|
||||
rejectedPeakCount: number;
|
||||
confidence: number;
|
||||
selectedChannel: string;
|
||||
}
|
||||
|
||||
export interface PRVMetrics {
|
||||
estimatedRmssdMs: number | null;
|
||||
estimatedSdnnMs: number | null;
|
||||
meanIpiMs: number | null;
|
||||
medianIpiMs: number | null;
|
||||
pnn50Percent: number | null;
|
||||
rangeIpiMs: number | null;
|
||||
modeIpiMs: number | null;
|
||||
amo50Percent: number | null;
|
||||
coefficientOfVariation: number | null;
|
||||
validIntervalCount: number;
|
||||
rejectedIntervalCount: number;
|
||||
rejectedIntervalRatio: number;
|
||||
reliable: boolean;
|
||||
}
|
||||
|
||||
export interface SignalQualityResult {
|
||||
score: number;
|
||||
level: SignalQualityLevel;
|
||||
usableDurationSeconds: number;
|
||||
droppedFrameRatio: number;
|
||||
saturationRatio: number;
|
||||
rejectedIntervalRatio: number;
|
||||
signalToNoiseRatio: number | null;
|
||||
motionArtifactRatio: number;
|
||||
methodAgreementScore: number;
|
||||
timestampJitterRatio: number;
|
||||
longestGapSeconds: number;
|
||||
issues: readonly SignalQualityIssue[];
|
||||
}
|
||||
|
||||
export interface PPGProcessingResult {
|
||||
valid: boolean;
|
||||
invalidReason: string | null;
|
||||
bpm: BPMEstimate;
|
||||
prv: PRVMetrics;
|
||||
quality: SignalQualityResult;
|
||||
startedAtMonotonicSeconds: number;
|
||||
endedAtMonotonicSeconds: number;
|
||||
rawSampleCount: number;
|
||||
resampledSampleCount: number;
|
||||
}
|
||||
|
||||
export type MeasurementFailureCode =
|
||||
| 'cameraPermissionDenied'
|
||||
| 'cameraRestricted'
|
||||
| 'cameraUnavailable'
|
||||
| 'torchUnavailable'
|
||||
| 'cameraRuntime'
|
||||
| 'cameraInterrupted'
|
||||
| 'pluginFailure'
|
||||
| 'processingFailure'
|
||||
| 'poorSignal'
|
||||
| 'insufficientDuration'
|
||||
| 'thermalSerious'
|
||||
| 'thermalCritical'
|
||||
| 'applicationInterrupted'
|
||||
| 'memoryPressure'
|
||||
| 'unknown';
|
||||
|
||||
export interface MeasurementFailure {
|
||||
code: MeasurementFailureCode;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
}
|
||||
|
||||
export interface MeasurementMachineContext {
|
||||
attempt: number;
|
||||
shouldRequestHealthKit: boolean;
|
||||
cameraPermission: CameraPermissionStatus;
|
||||
healthPermission: HealthPermissionStatus;
|
||||
contactState: FingerContactState;
|
||||
thermalState: ThermalState;
|
||||
failure: MeasurementFailure | null;
|
||||
result: PPGProcessingResult | null;
|
||||
}
|
||||
|
||||
export interface MeasurementMachineSnapshot {
|
||||
value: MeasurementState;
|
||||
context: MeasurementMachineContext;
|
||||
}
|
||||
|
||||
export type MeasurementEvent =
|
||||
| {type: 'START'; requestHealthKit?: boolean}
|
||||
| {type: 'CAMERA_PERMISSION_GRANTED'}
|
||||
| {type: 'CAMERA_PERMISSION_DENIED'; restricted?: boolean}
|
||||
| {
|
||||
type: 'HEALTH_PERMISSION_RESOLVED';
|
||||
status: Extract<
|
||||
HealthPermissionStatus,
|
||||
'requestCompleted' | 'requestFailed' | 'unavailable'
|
||||
>;
|
||||
}
|
||||
| {type: 'CAMERA_READY'}
|
||||
| {type: 'CAMERA_PREPARATION_FAILED'; failure: MeasurementFailure}
|
||||
| {type: 'CONTACT_CHANGED'; contactState: FingerContactState}
|
||||
| {type: 'STABILIZATION_COMPLETE'}
|
||||
| {type: 'ACQUISITION_COMPLETE'}
|
||||
| {type: 'PROCESSING_SUCCEEDED'; result: PPGProcessingResult}
|
||||
| {
|
||||
type: 'PROCESSING_INVALID';
|
||||
reason: string;
|
||||
result?: PPGProcessingResult;
|
||||
}
|
||||
| {type: 'THERMAL_STATE_CHANGED'; thermalState: ThermalState}
|
||||
| {type: 'INTERRUPTED'; reason: string}
|
||||
| {type: 'FAIL'; failure: MeasurementFailure}
|
||||
| {type: 'CANCEL'}
|
||||
| {type: 'RETRY'}
|
||||
| {type: 'RESET'};
|
||||
@@ -0,0 +1,490 @@
|
||||
import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
|
||||
import {AppState, type AppStateStatus} from 'react-native';
|
||||
import {
|
||||
haptikaHealthKit,
|
||||
haptikaPPG,
|
||||
type NativePPGConfiguration,
|
||||
type PPGFrameUpdate,
|
||||
} from 'react-native-haptika-ppg';
|
||||
|
||||
import type {PPGCameraError} from '../camera/cameraTypes';
|
||||
import {
|
||||
createMeasurementConfiguration,
|
||||
type MeasurementConfiguration,
|
||||
} from './measurementConfiguration';
|
||||
import {mapNativeProcessingResult} from './measurementCoordinator';
|
||||
import {
|
||||
createInitialMeasurementMachine,
|
||||
transitionMeasurement,
|
||||
} from './measurementMachine';
|
||||
import type {
|
||||
MeasurementEvent,
|
||||
MeasurementMachineSnapshot,
|
||||
ThermalState,
|
||||
} from './measurementTypes';
|
||||
|
||||
interface StartMeasurementOptions {
|
||||
includeHealthKitContext: boolean;
|
||||
sensitiveContact: boolean;
|
||||
requestCameraPermission: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PPGMeasurementController {
|
||||
snapshot: MeasurementMachineSnapshot;
|
||||
latestFrame: PPGFrameUpdate | null;
|
||||
phaseProgress: number;
|
||||
isCameraActive: boolean;
|
||||
torchEnabled: boolean;
|
||||
start: (options: StartMeasurementOptions) => Promise<void>;
|
||||
retry: () => void;
|
||||
cancel: () => void;
|
||||
reset: () => void;
|
||||
onCameraStarted: () => void;
|
||||
onCameraError: (error: PPGCameraError) => void;
|
||||
onCameraInterrupted: (reason: string) => void;
|
||||
onFrameUpdate: (update: PPGFrameUpdate) => void;
|
||||
}
|
||||
|
||||
const CAMERA_STATES = new Set([
|
||||
'preparingCamera',
|
||||
'waitingForFinger',
|
||||
'stabilizing',
|
||||
'measuring',
|
||||
]);
|
||||
|
||||
const TERMINAL_STATES = new Set([
|
||||
'idle',
|
||||
'invalid',
|
||||
'cancelled',
|
||||
'thermalStop',
|
||||
'interrupted',
|
||||
'error',
|
||||
]);
|
||||
|
||||
function nativeConfiguration(
|
||||
configuration: MeasurementConfiguration,
|
||||
): NativePPGConfiguration {
|
||||
return {
|
||||
sessionPurpose: 'spotMeasurement',
|
||||
targetSampleRate: configuration.targetSampleRate,
|
||||
minimumFrequencyHz: configuration.minimumFrequencyHz,
|
||||
maximumFrequencyHz: configuration.maximumFrequencyHz,
|
||||
stabilizationSeconds: configuration.stabilizationSeconds,
|
||||
measurementSeconds: configuration.measurementSeconds,
|
||||
emitEveryFrames: configuration.emitEveryFrames,
|
||||
minimumContactConfidence: configuration.minimumContactConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePPGMeasurement(): PPGMeasurementController {
|
||||
const configuration = useMemo(() => createMeasurementConfiguration(), []);
|
||||
const [snapshot, dispatch] = useReducer(
|
||||
transitionMeasurement,
|
||||
undefined,
|
||||
createInitialMeasurementMachine,
|
||||
);
|
||||
const [latestFrame, setLatestFrame] = useState<PPGFrameUpdate | null>(null);
|
||||
const [phaseProgress, setPhaseProgress] = useState(0);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
const latestFrameRef = useRef<PPGFrameUpdate | null>(null);
|
||||
const phaseStartedAtRef = useRef<number | null>(null);
|
||||
const lastStabilizationFrameAtRef = useRef<number | null>(null);
|
||||
const lastStabilizationFrameWasValidRef = useRef(false);
|
||||
const stableDurationRef = useRef(0);
|
||||
const measurementStartedAtUsableDurationRef = useRef(0);
|
||||
const measurementStartedAtBeatRef = useRef(0);
|
||||
const nativeMeasurementStartedRef = useRef(false);
|
||||
const processingStartedRef = useRef(false);
|
||||
const minimumContactConfidenceRef = useRef(
|
||||
configuration.minimumContactConfidence,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
snapshotRef.current = snapshot;
|
||||
}, [snapshot]);
|
||||
|
||||
const send = useCallback((event: MeasurementEvent): void => {
|
||||
dispatch(event);
|
||||
}, []);
|
||||
|
||||
const stopNativeMeasurement = useCallback((): void => {
|
||||
if (!nativeMeasurementStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
haptikaPPG.cancelMeasurement();
|
||||
} catch {
|
||||
// Best effort: the native session may already have stopped itself.
|
||||
} finally {
|
||||
nativeMeasurementStartedRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshThermalState = useCallback((): ThermalState | null => {
|
||||
try {
|
||||
const thermalState = haptikaPPG.getThermalState();
|
||||
send({type: 'THERMAL_STATE_CHANGED', thermalState});
|
||||
return thermalState;
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'pluginFailure',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Impossible de vérifier la température de l’iPhone.',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot.value === 'preparingCamera' && !nativeMeasurementStartedRef.current) {
|
||||
try {
|
||||
haptikaPPG.startMeasurement({
|
||||
...nativeConfiguration(configuration),
|
||||
minimumContactConfidence: minimumContactConfidenceRef.current,
|
||||
});
|
||||
nativeMeasurementStartedRef.current = true;
|
||||
processingStartedRef.current = false;
|
||||
setLatestFrame(null);
|
||||
latestFrameRef.current = null;
|
||||
setPhaseProgress(0);
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'pluginFailure',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Le module PPG natif n’a pas pu démarrer.',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot.value === 'stabilizing' || snapshot.value === 'measuring') {
|
||||
phaseStartedAtRef.current = latestFrameRef.current?.timestampSeconds ?? null;
|
||||
lastStabilizationFrameAtRef.current = null;
|
||||
lastStabilizationFrameWasValidRef.current = false;
|
||||
stableDurationRef.current = 0;
|
||||
if (snapshot.value === 'measuring') {
|
||||
measurementStartedAtBeatRef.current =
|
||||
latestFrameRef.current?.beatSequence ?? 0;
|
||||
measurementStartedAtUsableDurationRef.current =
|
||||
latestFrameRef.current?.usableDurationSeconds ?? 0;
|
||||
}
|
||||
setPhaseProgress(0);
|
||||
}
|
||||
|
||||
if (snapshot.value === 'processing' && !processingStartedRef.current) {
|
||||
processingStartedRef.current = true;
|
||||
try {
|
||||
const result = mapNativeProcessingResult(haptikaPPG.finishMeasurement());
|
||||
nativeMeasurementStartedRef.current = false;
|
||||
send({type: 'PROCESSING_SUCCEEDED', result});
|
||||
} catch (error) {
|
||||
nativeMeasurementStartedRef.current = false;
|
||||
send({
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'processingFailure',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Le traitement du signal a échoué.',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (TERMINAL_STATES.has(snapshot.value)) {
|
||||
stopNativeMeasurement();
|
||||
}
|
||||
}, [configuration, send, snapshot.value, stopNativeMeasurement]);
|
||||
|
||||
useEffect(() => {
|
||||
const onAppStateChange = (nextState: AppStateStatus): void => {
|
||||
if (
|
||||
nextState !== 'active' &&
|
||||
CAMERA_STATES.has(snapshotRef.current.value)
|
||||
) {
|
||||
stopNativeMeasurement();
|
||||
send({
|
||||
type: 'INTERRUPTED',
|
||||
reason: 'La mesure a été interrompue lorsque Haptika a quitté l’écran actif.',
|
||||
});
|
||||
}
|
||||
};
|
||||
const subscription = AppState.addEventListener('change', onAppStateChange);
|
||||
const memorySubscription = AppState.addEventListener(
|
||||
'memoryWarning',
|
||||
(): void => {
|
||||
if (CAMERA_STATES.has(snapshotRef.current.value)) {
|
||||
stopNativeMeasurement();
|
||||
send({
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'memoryPressure',
|
||||
message:
|
||||
'La mesure a été arrêtée pour libérer les ressources de l’iPhone.',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
memorySubscription.remove();
|
||||
};
|
||||
}, [send, stopNativeMeasurement]);
|
||||
|
||||
useEffect(() => {
|
||||
return stopNativeMeasurement;
|
||||
}, [stopNativeMeasurement]);
|
||||
|
||||
const start = useCallback(
|
||||
async ({
|
||||
includeHealthKitContext,
|
||||
sensitiveContact,
|
||||
requestCameraPermission,
|
||||
}: StartMeasurementOptions): Promise<void> => {
|
||||
minimumContactConfidenceRef.current = sensitiveContact
|
||||
? 0.45
|
||||
: configuration.minimumContactConfidence;
|
||||
send({type: 'START', requestHealthKit: includeHealthKitContext});
|
||||
try {
|
||||
const cameraGranted = await requestCameraPermission();
|
||||
if (!cameraGranted) {
|
||||
send({type: 'CAMERA_PERMISSION_DENIED'});
|
||||
return;
|
||||
}
|
||||
send({type: 'CAMERA_PERMISSION_GRANTED'});
|
||||
|
||||
if (includeHealthKitContext) {
|
||||
if (!haptikaHealthKit.isAvailable()) {
|
||||
send({
|
||||
type: 'HEALTH_PERMISSION_RESOLVED',
|
||||
status: 'unavailable',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const requestCompleted =
|
||||
await haptikaHealthKit.requestReadAuthorization();
|
||||
send({
|
||||
type: 'HEALTH_PERMISSION_RESOLVED',
|
||||
status: requestCompleted
|
||||
? 'requestCompleted'
|
||||
: 'requestFailed',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'unknown',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Impossible de préparer la mesure.',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[configuration.minimumContactConfidence, send],
|
||||
);
|
||||
|
||||
const onFrameUpdate = useCallback(
|
||||
(update: PPGFrameUpdate): void => {
|
||||
latestFrameRef.current = update;
|
||||
setLatestFrame(update);
|
||||
send({
|
||||
type: 'THERMAL_STATE_CHANGED',
|
||||
thermalState: update.thermalState,
|
||||
});
|
||||
|
||||
const current = snapshotRef.current;
|
||||
if (
|
||||
update.thermalState === 'serious' ||
|
||||
update.thermalState === 'critical'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (current.context.contactState !== update.contactState) {
|
||||
send({type: 'CONTACT_CHANGED', contactState: update.contactState});
|
||||
}
|
||||
|
||||
if (current.value === 'stabilizing') {
|
||||
const previous = lastStabilizationFrameAtRef.current;
|
||||
const previousWasValid = lastStabilizationFrameWasValidRef.current;
|
||||
lastStabilizationFrameAtRef.current = update.timestampSeconds;
|
||||
lastStabilizationFrameWasValidRef.current =
|
||||
update.contactState === 'valid';
|
||||
if (
|
||||
update.contactState === 'valid' &&
|
||||
previousWasValid &&
|
||||
previous !== null
|
||||
) {
|
||||
const delta = update.timestampSeconds - previous;
|
||||
if (delta > 0 && delta <= 0.5) {
|
||||
stableDurationRef.current += delta;
|
||||
}
|
||||
}
|
||||
const stableDuration = stableDurationRef.current;
|
||||
setPhaseProgress(
|
||||
Math.min(stableDuration / configuration.stabilizationSeconds, 1),
|
||||
);
|
||||
if (stableDuration >= configuration.stabilizationSeconds) {
|
||||
send({type: 'STABILIZATION_COMPLETE'});
|
||||
}
|
||||
}
|
||||
|
||||
if (current.value === 'measuring') {
|
||||
// Keep the acquisition clock running through brief contact instability.
|
||||
// Native processing still stores only valid frames and reports the
|
||||
// resulting quality at the end.
|
||||
const startTime = phaseStartedAtRef.current ?? update.timestampSeconds;
|
||||
phaseStartedAtRef.current = startTime;
|
||||
const elapsed = Math.max(0, update.timestampSeconds - startTime);
|
||||
const collectedBeats = Math.max(
|
||||
0,
|
||||
update.beatSequence - measurementStartedAtBeatRef.current,
|
||||
);
|
||||
const usableDuration = Math.max(
|
||||
0,
|
||||
update.usableDurationSeconds -
|
||||
measurementStartedAtUsableDurationRef.current,
|
||||
);
|
||||
const timeProgress =
|
||||
usableDuration / configuration.minimumMeasurementSeconds;
|
||||
const beatProgress =
|
||||
collectedBeats / configuration.targetValidBeatCount;
|
||||
const readyFromSignal =
|
||||
usableDuration >= configuration.minimumMeasurementSeconds &&
|
||||
collectedBeats >= configuration.targetValidBeatCount;
|
||||
const reachedSafetyLimit = elapsed >= configuration.measurementSeconds;
|
||||
setPhaseProgress(
|
||||
readyFromSignal || reachedSafetyLimit
|
||||
? 1
|
||||
: Math.min(Math.min(timeProgress, beatProgress), 0.99),
|
||||
);
|
||||
if (readyFromSignal) {
|
||||
send({type: 'ACQUISITION_COMPLETE'});
|
||||
} else if (reachedSafetyLimit) {
|
||||
if (update.collectedSampleCount >= 300 && usableDuration >= 20) {
|
||||
// The native pipeline may still publish a conservative BPM while
|
||||
// explicitly marking beat-to-beat variability as unreliable.
|
||||
send({type: 'ACQUISITION_COMPLETE'});
|
||||
} else {
|
||||
send({
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'poorSignal',
|
||||
message:
|
||||
update.collectedSampleCount < 300
|
||||
? 'La caméra ne transmet pas assez de trames pour mesurer le pouls.'
|
||||
: 'Moins de 20 secondes de signal optique ont été utilisables.',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
configuration.measurementSeconds,
|
||||
configuration.minimumMeasurementSeconds,
|
||||
configuration.stabilizationSeconds,
|
||||
configuration.targetValidBeatCount,
|
||||
send,
|
||||
],
|
||||
);
|
||||
|
||||
const onCameraStarted = useCallback((): void => {
|
||||
send({type: 'CAMERA_READY'});
|
||||
}, [send]);
|
||||
|
||||
const onCameraError = useCallback(
|
||||
(error: PPGCameraError): void => {
|
||||
stopNativeMeasurement();
|
||||
send({
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
[send, stopNativeMeasurement],
|
||||
);
|
||||
|
||||
const onCameraInterrupted = useCallback(
|
||||
(reason: string): void => {
|
||||
stopNativeMeasurement();
|
||||
send({type: 'INTERRUPTED', reason});
|
||||
},
|
||||
[send, stopNativeMeasurement],
|
||||
);
|
||||
|
||||
const retry = useCallback((): void => {
|
||||
stopNativeMeasurement();
|
||||
processingStartedRef.current = false;
|
||||
phaseStartedAtRef.current = null;
|
||||
|
||||
if (snapshotRef.current.value === 'thermalStop') {
|
||||
const thermalState = refreshThermalState();
|
||||
if (thermalState !== 'nominal' && thermalState !== 'fair') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
send({type: 'RETRY'});
|
||||
}, [refreshThermalState, send, stopNativeMeasurement]);
|
||||
|
||||
const cancel = useCallback((): void => {
|
||||
stopNativeMeasurement();
|
||||
send({type: 'CANCEL'});
|
||||
}, [send, stopNativeMeasurement]);
|
||||
const reset = useCallback((): void => {
|
||||
stopNativeMeasurement();
|
||||
processingStartedRef.current = false;
|
||||
phaseStartedAtRef.current = null;
|
||||
setLatestFrame(null);
|
||||
setPhaseProgress(0);
|
||||
|
||||
if (snapshotRef.current.value === 'thermalStop') {
|
||||
const thermalState = refreshThermalState();
|
||||
if (thermalState !== 'nominal' && thermalState !== 'fair') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
send({type: 'RESET'});
|
||||
}, [refreshThermalState, send, stopNativeMeasurement]);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
latestFrame,
|
||||
phaseProgress,
|
||||
isCameraActive: CAMERA_STATES.has(snapshot.value),
|
||||
torchEnabled:
|
||||
snapshot.value === 'waitingForFinger' ||
|
||||
snapshot.value === 'stabilizing' ||
|
||||
snapshot.value === 'measuring',
|
||||
start,
|
||||
retry,
|
||||
cancel,
|
||||
reset,
|
||||
onCameraStarted,
|
||||
onCameraError,
|
||||
onCameraInterrupted,
|
||||
onFrameUpdate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Linking, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
import {PrimaryButton} from '../components/PrimaryButton';
|
||||
import type {
|
||||
MeasurementState,
|
||||
PPGProcessingResult,
|
||||
SignalQualityIssue,
|
||||
} from '../measurement/measurementTypes';
|
||||
|
||||
interface PPGFailureScreenProps {
|
||||
state: MeasurementState;
|
||||
message: string;
|
||||
result?: PPGProcessingResult | null;
|
||||
showOpenSettings?: boolean;
|
||||
onRetry: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ISSUE_LABELS: Record<SignalQualityIssue, string> = {
|
||||
contactInsufficient: 'contact caméra insuffisant',
|
||||
contactInterrupted: 'contact interrompu',
|
||||
durationInsufficient: 'durée utile insuffisante',
|
||||
droppedFrames: 'images perdues',
|
||||
timestampJitter: 'cadence caméra irrégulière',
|
||||
longGap: 'interruption trop longue',
|
||||
saturation: 'image trop lumineuse / saturée',
|
||||
motionArtifacts: 'mouvements détectés',
|
||||
lowPulsatileAmplitude: 'pulsations trop faibles',
|
||||
lowSignalToNoiseRatio: 'signal trop bruité',
|
||||
tooFewPeaks: 'trop peu de battements détectés',
|
||||
tooManyRejectedPeaks: 'trop de battements rejetés',
|
||||
tooManyRejectedIntervals: 'intervalles trop irréguliers',
|
||||
methodDisagreement: 'estimations du rythme incohérentes',
|
||||
channelInstability: 'couleur du signal instable',
|
||||
nonFiniteProcessingResult: 'calcul du signal impossible',
|
||||
};
|
||||
|
||||
const percent = (value: number): string => `${Math.round(value * 100)} %`;
|
||||
|
||||
function qualityLabel(result: PPGProcessingResult): string {
|
||||
switch (result.quality.level) {
|
||||
case 'good':
|
||||
return 'bonne';
|
||||
case 'medium':
|
||||
return 'moyenne';
|
||||
case 'poor':
|
||||
return 'faible';
|
||||
default:
|
||||
return 'invalide';
|
||||
}
|
||||
}
|
||||
|
||||
export function PPGFailureScreen({
|
||||
state,
|
||||
message,
|
||||
result = null,
|
||||
showOpenSettings = false,
|
||||
onRetry,
|
||||
onBack,
|
||||
}: PPGFailureScreenProps): React.JSX.Element {
|
||||
const thermal = state === 'thermalStop';
|
||||
const insets = useSafeAreaInsets();
|
||||
const [showDiagnostic, setShowDiagnostic] = useState(false);
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{paddingTop: insets.top + spacing.lg},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.icon}>
|
||||
<Text style={styles.iconText}>{thermal ? '°' : '!'}</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>
|
||||
{thermal ? 'L’iPhone doit refroidir' : 'Mesure non exploitable'}
|
||||
</Text>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
{!thermal && result !== null ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{expanded: showDiagnostic}}
|
||||
onPress={() => setShowDiagnostic(value => !value)}
|
||||
style={styles.diagnosticToggle}>
|
||||
<Text style={styles.tipTitle}>Détails techniques</Text>
|
||||
<Text style={styles.diagnosticChevron}>{showDiagnostic ? '−' : '+'}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!thermal && result !== null && showDiagnostic ? (
|
||||
<View style={styles.diagnosticCard}>
|
||||
<Text style={styles.tipTitle}>Diagnostic de cette mesure</Text>
|
||||
<Text style={styles.diagnosticLine}>
|
||||
Qualité : {percent(result.quality.score)} ({qualityLabel(result)})
|
||||
</Text>
|
||||
<Text style={styles.diagnosticLine}>
|
||||
Signal utile : {result.quality.usableDurationSeconds.toFixed(1)} s ·
|
||||
mouvement : {percent(result.quality.motionArtifactRatio)}
|
||||
</Text>
|
||||
<Text style={styles.diagnosticLine}>
|
||||
Saturation : {percent(result.quality.saturationRatio)} · pertes :{' '}
|
||||
{percent(result.quality.droppedFrameRatio)}
|
||||
</Text>
|
||||
<Text style={styles.diagnosticLine}>
|
||||
SNR :{' '}
|
||||
{result.quality.signalToNoiseRatio === null
|
||||
? 'indisponible'
|
||||
: result.quality.signalToNoiseRatio.toFixed(1)}{' '}
|
||||
· battements : {result.bpm.validPeakCount}
|
||||
</Text>
|
||||
<Text style={styles.diagnosticLine}>
|
||||
Trames : {result.rawSampleCount} · rééchantillonnées :{' '}
|
||||
{result.resampledSampleCount}
|
||||
</Text>
|
||||
{result.quality.issues.length > 0 ? (
|
||||
<Text style={styles.issueText}>
|
||||
Cause(s) :{' '}
|
||||
{result.quality.issues.map(issue => ISSUE_LABELS[issue]).join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.tipCard}>
|
||||
<Text style={styles.tipTitle}>Pour le prochain essai</Text>
|
||||
<Text style={styles.tipText}>
|
||||
Posez le doigt doucement, couvrez entièrement la caméra choisie et
|
||||
gardez la main immobile. Avec la caméra arrière, couvrez aussi le flash.
|
||||
</Text>
|
||||
</View>
|
||||
<PrimaryButton
|
||||
label={thermal ? 'Réessayer plus tard' : 'Recommencer'}
|
||||
onPress={onRetry}
|
||||
/>
|
||||
{showOpenSettings ? (
|
||||
<PrimaryButton
|
||||
label="Ouvrir les réglages de l’iPhone"
|
||||
variant="secondary"
|
||||
onPress={() => Linking.openSettings().catch(() => undefined)}
|
||||
/>
|
||||
) : null}
|
||||
<PrimaryButton label="Retour" variant="ghost" onPress={onBack} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
padding: spacing.lg,
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
icon: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.poor,
|
||||
backgroundColor: colors.accentSoft,
|
||||
},
|
||||
iconText: {
|
||||
color: colors.poor,
|
||||
fontSize: 27,
|
||||
fontWeight: '900',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 32,
|
||||
lineHeight: 37,
|
||||
fontWeight: '800',
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
message: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
diagnosticCard: {
|
||||
padding: spacing.md,
|
||||
borderRadius: radii.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accent,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
diagnosticToggle: {
|
||||
minHeight: 54,
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
diagnosticChevron: {
|
||||
color: colors.accent,
|
||||
fontSize: 25,
|
||||
fontWeight: '300',
|
||||
},
|
||||
diagnosticLine: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
issueText: {
|
||||
color: colors.poor,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: '700',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
tipCard: {
|
||||
padding: spacing.md,
|
||||
borderRadius: radii.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
marginVertical: spacing.lg,
|
||||
},
|
||||
tipTitle: {
|
||||
color: colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
tipText: {
|
||||
color: colors.muted,
|
||||
lineHeight: 20,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import React, {useMemo, useState} from 'react';
|
||||
import {Alert, Pressable, ScrollView, Share, StyleSheet, Switch, Text, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
import {compareRegulationMeasurements} from '../../cardiacMirror/regulationComparison';
|
||||
import {getRegulationProtocol} from '../../cardiacMirror/regulationProtocols';
|
||||
import type {RegulationSession} from '../../cardiacMirror/regulationSessionTypes';
|
||||
import {PrimaryButton} from '../components/PrimaryButton';
|
||||
import {MetricTrendChart} from '../components/MetricTrendChart';
|
||||
import type {MetricTrendPoint} from '../insights/metricInsights';
|
||||
import {buildDailyTrends, buildFeelingCorrelation} from '../history/historyAnalytics';
|
||||
import type {BaselineCalculationResult} from '../stress/baselineTypes';
|
||||
import {
|
||||
exportMeasurementsAsCSV,
|
||||
exportMeasurementsAsJSON,
|
||||
} from '../storage/measurementRepository';
|
||||
import type {
|
||||
DataRetentionPolicy,
|
||||
StoredMeasurement,
|
||||
} from '../storage/storageTypes';
|
||||
import {buildWatchDailyTrend, compareWatchRegulationSession} from '../../watch/watchAnalytics';
|
||||
import type {WatchBaselineV1} from '../../watch/watchBaseline';
|
||||
import type {
|
||||
WatchConnectionState,
|
||||
WatchMeasurementSummaryV1,
|
||||
WatchRegulationSessionV1,
|
||||
} from '../../watch/watchTypes';
|
||||
|
||||
type SourceFilter = 'all' | 'camera' | 'watch' | 'front' | 'back';
|
||||
|
||||
interface PPGHistoryScreenProps {
|
||||
measurements: readonly StoredMeasurement[];
|
||||
watchMeasurements: readonly WatchMeasurementSummaryV1[];
|
||||
baselineProgress: BaselineCalculationResult | null;
|
||||
watchBaseline: WatchBaselineV1 | null;
|
||||
regulationSessions: readonly RegulationSession[];
|
||||
watchRegulationSessions: readonly WatchRegulationSessionV1[];
|
||||
watchConnectionState: WatchConnectionState;
|
||||
saveMindAndBodyWorkouts: boolean;
|
||||
retentionPolicy: DataRetentionPolicy;
|
||||
onDeleteMeasurement: (measurementId: string) => Promise<void>;
|
||||
onDeleteWatchMeasurement: (measurementId: string) => Promise<void>;
|
||||
onClearAll: () => Promise<void>;
|
||||
onSaveMindAndBodyWorkoutsChange: (value: boolean) => void;
|
||||
onRetentionPolicyChange: (policy: DataRetentionPolicy) => Promise<void>;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function WatchMeasurementDetail({
|
||||
measurement,
|
||||
watchBaseline,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
measurement: WatchMeasurementSummaryV1;
|
||||
watchBaseline: WatchBaselineV1 | null;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
}): React.JSX.Element {
|
||||
const delta = measurement.averageBpm === null || watchBaseline === null
|
||||
? null
|
||||
: measurement.averageBpm - watchBaseline.medianBpm;
|
||||
return <View style={styles.detailCard}>
|
||||
<View style={styles.row}>
|
||||
<View><Text style={styles.eyebrow}>MESURE APPLE WATCH</Text><Text style={styles.detailTitle}>{new Date(measurement.endedAt).toLocaleString('fr-FR')}</Text></View>
|
||||
<Pressable onPress={onClose}><Text style={styles.close}>Fermer</Text></Pressable>
|
||||
</View>
|
||||
<View style={styles.detailGrid}>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Pouls moyen</Text><Text style={styles.metricValue}>{formatMetric(measurement.averageBpm, 'BPM')}</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Minimum / maximum</Text><Text style={styles.metricValue}>{measurement.minimumBpm === null || measurement.maximumBpm === null ? '—' : `${Math.round(measurement.minimumBpm)}–${Math.round(measurement.maximumBpm)}`}</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Qualité</Text><Text style={styles.metricValue}>{Math.round(measurement.quality * 100)} %</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Couverture</Text><Text style={styles.metricValue}>{Math.round(measurement.coverage * 100)} %</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Écart baseline Watch</Text><Text style={styles.metricValue}>{delta === null ? '—' : `${delta >= 0 ? '+' : ''}${Math.round(delta)} BPM`}</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Ressenti</Text><Text style={styles.metricValue}>{measurement.feeling === null ? '—' : `${measurement.feeling.score}/5`}</Text></View>
|
||||
</View>
|
||||
<Text style={styles.detailText}>{measurement.device.name} · {measurement.device.model ?? 'modèle non renseigné'} · capteur optique Apple Watch</Text>
|
||||
<Text style={styles.detailText}>{measurement.samples.length} échantillons de fréquence cardiaque. Aucune PRV/RMSSD n’est calculée à partir de cette source.</Text>
|
||||
<PrimaryButton label="Supprimer cette mesure" variant="secondary" onPress={onDelete} />
|
||||
</View>;
|
||||
}
|
||||
|
||||
const formatMetric = (value: number | null, unit: string): string =>
|
||||
value === null ? '—' : `${Math.round(value)} ${unit}`;
|
||||
|
||||
function TrendChart({
|
||||
points,
|
||||
color,
|
||||
unit,
|
||||
}: {
|
||||
points: readonly {day: string; value: number | null}[];
|
||||
color: string;
|
||||
unit: string;
|
||||
}): React.JSX.Element {
|
||||
const trend = points.flatMap<MetricTrendPoint>((point, index) =>
|
||||
point.value === null || !Number.isFinite(point.value)
|
||||
? []
|
||||
: [{id: `${point.day}-${index}`, measuredAt: point.day, value: point.value}],
|
||||
);
|
||||
return (
|
||||
<MetricTrendChart
|
||||
points={trend}
|
||||
color={color}
|
||||
unit={unit}
|
||||
digits={0}
|
||||
referenceRange={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MeasurementDetail({
|
||||
measurement,
|
||||
baselineProgress,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
measurement: StoredMeasurement;
|
||||
baselineProgress: BaselineCalculationResult | null;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
}): React.JSX.Element {
|
||||
const result = measurement.cameraResult;
|
||||
const prv = result.prv;
|
||||
const baselineBpm = baselineProgress?.baseline?.medianBpm ?? null;
|
||||
const delta = result.bpm.bpm === null || baselineBpm === null
|
||||
? null
|
||||
: result.bpm.bpm - baselineBpm;
|
||||
return (
|
||||
<View style={styles.detailCard}>
|
||||
<View style={styles.row}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>DÉTAIL DE LA MESURE</Text>
|
||||
<Text style={styles.detailTitle}>
|
||||
{new Date(measurement.measuredAt).toLocaleString('fr-FR')}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} accessibilityRole="button">
|
||||
<Text style={styles.close}>Fermer</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.detailGrid}>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Pouls moyen</Text><Text style={styles.metricValue}>{formatMetric(result.bpm.bpm, 'BPM')}</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>RMSSD pulsatile</Text><Text style={styles.metricValue}>{formatMetric(prv.estimatedRmssdMs, 'ms')}</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>SDNN pulsatile</Text><Text style={styles.metricValue}>{formatMetric(prv.estimatedSdnnMs, 'ms')}</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Qualité</Text><Text style={styles.metricValue}>{Math.round(result.quality.score * 100)} %</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Écart baseline</Text><Text style={styles.metricValue}>{delta === null ? '—' : `${delta >= 0 ? '+' : ''}${Math.round(delta)} BPM`}</Text></View>
|
||||
<View style={styles.metricTile}><Text style={styles.metricLabel}>Ressenti</Text><Text style={styles.metricValue}>{measurement.selfReport === null ? '—' : `${measurement.selfReport.feeling}/5`}</Text></View>
|
||||
</View>
|
||||
<Text style={styles.detailText}>
|
||||
Caméra {measurement.acquisition.cameraPosition === 'unknown' ? 'non renseignée' : measurement.acquisition.cameraPosition === 'front' ? 'avant' : 'arrière'}
|
||||
{' · '}flash {measurement.acquisition.torchUsed === null ? 'non renseigné' : measurement.acquisition.torchUsed ? 'utilisé' : 'non utilisé'}
|
||||
{' · '}signal utile {result.quality.usableDurationSeconds.toFixed(1)} s
|
||||
</Text>
|
||||
<Text style={styles.detailText}>
|
||||
IPI valides {prv.validIntervalCount} · rejetés {prv.rejectedIntervalCount} · indice {measurement.potentialStress.index ?? 'indisponible'}
|
||||
</Text>
|
||||
<Text style={styles.detailText}>
|
||||
Baseline : {measurement.baselineEligible ? 'mesure comparable' : 'mesure exclue'} · algorithme {measurement.algorithmVersion ?? 'non renseigné'}
|
||||
</Text>
|
||||
<PrimaryButton label="Supprimer cette mesure" variant="secondary" onPress={onDelete} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function PPGHistoryScreen({
|
||||
measurements,
|
||||
watchMeasurements,
|
||||
baselineProgress,
|
||||
watchBaseline,
|
||||
regulationSessions,
|
||||
watchRegulationSessions,
|
||||
watchConnectionState,
|
||||
saveMindAndBodyWorkouts,
|
||||
retentionPolicy,
|
||||
onDeleteMeasurement,
|
||||
onDeleteWatchMeasurement,
|
||||
onClearAll,
|
||||
onSaveMindAndBodyWorkoutsChange,
|
||||
onRetentionPolicyChange,
|
||||
onBack,
|
||||
}: PPGHistoryScreenProps): React.JSX.Element {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedWatchId, setSelectedWatchId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const filtered = useMemo(
|
||||
() => measurements.filter(measurement =>
|
||||
sourceFilter === 'all' || sourceFilter === 'camera' || measurement.acquisition.cameraPosition === sourceFilter,
|
||||
),
|
||||
[sourceFilter, measurements],
|
||||
);
|
||||
const filteredWatch = useMemo(
|
||||
() => sourceFilter === 'all' || sourceFilter === 'watch' ? watchMeasurements : [],
|
||||
[sourceFilter, watchMeasurements],
|
||||
);
|
||||
const trends = useMemo(() => buildDailyTrends(filtered, 30), [filtered]);
|
||||
const correlations = useMemo(() => buildFeelingCorrelation(filtered), [filtered]);
|
||||
const selected = measurements.find(item => item.id === selectedId) ?? null;
|
||||
const selectedWatch = watchMeasurements.find(item => item.id === selectedWatchId) ?? null;
|
||||
const watchTrends = useMemo(() => buildWatchDailyTrend(filteredWatch, 7), [filteredWatch]);
|
||||
const measurementById = new Map(measurements.map(item => [item.id, item]));
|
||||
const completedRegulations = regulationSessions.flatMap(session => {
|
||||
if (session.status !== 'completed' || session.afterMeasurementId === null) return [];
|
||||
const before = measurementById.get(session.beforeMeasurementId);
|
||||
const after = measurementById.get(session.afterMeasurementId);
|
||||
return before === undefined || after === undefined
|
||||
? []
|
||||
: [{session, comparison: compareRegulationMeasurements(before, after)}];
|
||||
});
|
||||
const completedWatchRegulations = watchRegulationSessions.flatMap(session => {
|
||||
const comparison = compareWatchRegulationSession(session, watchMeasurements);
|
||||
return comparison === null ? [] : [{session, comparison}];
|
||||
});
|
||||
|
||||
const confirmDelete = (measurement: StoredMeasurement): void => {
|
||||
Alert.alert('Supprimer cette mesure ?', 'Cette action est définitive et supprimera aussi les comparaisons liées.', [
|
||||
{text: 'Annuler', style: 'cancel'},
|
||||
{text: 'Supprimer', style: 'destructive', onPress: () => {
|
||||
setBusy(true);
|
||||
onDeleteMeasurement(measurement.id).then(() => setSelectedId(null)).finally(() => setBusy(false));
|
||||
}},
|
||||
]);
|
||||
};
|
||||
const confirmWatchDelete = (measurement: WatchMeasurementSummaryV1): void => {
|
||||
Alert.alert('Supprimer cette mesure Watch ?', 'La suppression sera synchronisée avec votre Apple Watch appairée.', [
|
||||
{text: 'Annuler', style: 'cancel'},
|
||||
{text: 'Supprimer', style: 'destructive', onPress: () => {
|
||||
setBusy(true);
|
||||
onDeleteWatchMeasurement(measurement.id).then(() => setSelectedWatchId(null)).finally(() => setBusy(false));
|
||||
}},
|
||||
]);
|
||||
};
|
||||
|
||||
const confirmClear = (): void => {
|
||||
Alert.alert('Effacer les données de mesure ?', 'Toutes les mesures et séances de régulation locales seront définitivement supprimées. Vos préférences seront conservées.', [
|
||||
{text: 'Annuler', style: 'cancel'},
|
||||
{text: 'Tout effacer', style: 'destructive', onPress: () => {
|
||||
setBusy(true);
|
||||
onClearAll().then(() => setSelectedId(null)).finally(() => setBusy(false));
|
||||
}},
|
||||
]);
|
||||
};
|
||||
|
||||
const shareExport = async (format: 'json' | 'csv'): Promise<void> => {
|
||||
const message = format === 'json'
|
||||
? exportMeasurementsAsJSON(measurements)
|
||||
: exportMeasurementsAsCSV(measurements);
|
||||
await Share.share({title: `Mesures Haptika (.${format})`, message});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={[styles.content, {paddingTop: insets.top + spacing.md}]} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.eyebrow}>DONNÉES LOCALES</Text>
|
||||
<Text style={styles.title}>Votre évolution</Text>
|
||||
<Text style={styles.subtitle}>Pouls, variabilité pulsatile et qualité, sans mélanger les sources de mesure.</Text>
|
||||
|
||||
{selected !== null ? (
|
||||
<MeasurementDetail measurement={selected} baselineProgress={baselineProgress} onClose={() => setSelectedId(null)} onDelete={() => confirmDelete(selected)} />
|
||||
) : null}
|
||||
{selectedWatch !== null ? <WatchMeasurementDetail measurement={selectedWatch} watchBaseline={watchBaseline} onClose={() => setSelectedWatchId(null)} onDelete={() => confirmWatchDelete(selectedWatch)} /> : null}
|
||||
|
||||
<View style={styles.filterRow}>
|
||||
{(['all', 'camera', 'watch', 'front', 'back'] as const).map(filter => (
|
||||
<Pressable key={filter} onPress={() => setSourceFilter(filter)} style={[styles.filter, sourceFilter === filter && styles.filterActive]} accessibilityRole="radio" accessibilityState={{checked: sourceFilter === filter}}>
|
||||
<Text style={[styles.filterText, sourceFilter === filter && styles.filterTextActive]}>{filter === 'all' ? 'Toutes' : filter === 'camera' ? 'Caméra' : filter === 'watch' ? 'Watch' : filter === 'front' ? 'Avant' : 'Arrière'}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.eyebrow}>30 DERNIERS JOURS</Text>
|
||||
<Text style={styles.sectionTitle}>{trends.reduce((sum, point) => sum + point.count, 0) + watchTrends.reduce((sum, point) => sum + point.count, 0)} mesures valides</Text>
|
||||
{filtered.length > 0 ? <><Text style={styles.chartTitle}>Pouls moyen · caméra iPhone</Text>
|
||||
<TrendChart
|
||||
points={trends.map(point => ({day: point.day, value: point.bpm}))}
|
||||
color={colors.accent}
|
||||
unit="BPM"
|
||||
/>
|
||||
<Text style={styles.chartTitle}>RMSSD pulsatile · caméra uniquement</Text>
|
||||
<TrendChart
|
||||
points={trends.map(point => ({day: point.day, value: point.rmssdMs}))}
|
||||
color={colors.good}
|
||||
unit="ms"
|
||||
/>
|
||||
<Text style={styles.chartTitle}>Qualité du signal</Text>
|
||||
<TrendChart
|
||||
points={trends.map(point => ({day: point.day, value: point.quality * 100}))}
|
||||
color={colors.medium}
|
||||
unit="%"
|
||||
/></> : null}
|
||||
{filteredWatch.length > 0 ? <><Text style={styles.chartTitle}>Pouls moyen · Apple Watch (7 jours)</Text><TrendChart points={watchTrends.map(point => ({day: point.day, value: point.averageBpm}))} color={colors.good} unit="BPM" /><Text style={styles.chartTitle}>Qualité Watch</Text><TrendChart points={watchTrends.map(point => ({day: point.day, value: point.averageQuality * 100}))} color={colors.accent} unit="%" /></> : null}
|
||||
<Text style={styles.caption}>{trends.length === 0 ? 'Pas encore assez de données.' : `${trends[0]?.day ?? ''} → ${trends[trends.length - 1]?.day ?? ''}`}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.eyebrow}>APPLE WATCH</Text>
|
||||
<Text style={styles.sectionTitle}>{watchConnectionState.watchAppInstalled ? watchConnectionState.reachable ? 'Connectée maintenant' : 'Installée · synchronisation différée' : watchConnectionState.paired ? 'App Watch non installée' : 'Aucune Watch appairée'}</Text>
|
||||
<Text style={styles.detailText}>Dernier événement : {watchConnectionState.latestEventAt === null ? 'aucun' : new Date(watchConnectionState.latestEventAt).toLocaleString('fr-FR')}</Text>
|
||||
<View style={styles.row}><Text style={styles.detailText}>Enregistrer les séances guidées dans Santé</Text><Switch value={saveMindAndBodyWorkouts} onValueChange={onSaveMindAndBodyWorkoutsChange} trackColor={{true: colors.accent}} /></View>
|
||||
<Text style={styles.caption}>Les mesures techniques de 60 secondes sont toujours écartées de l’historique des workouts Santé.</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.eyebrow}>BASELINE PERSONNELLE</Text>
|
||||
<Text style={styles.sectionTitle}>{baselineProgress?.baseline?.validMeasurementCount ?? 0} mesures comparables</Text>
|
||||
<Text style={styles.detailText}>Pouls médian {baselineProgress?.baseline === null || baselineProgress?.baseline === undefined ? '—' : `${Math.round(baselineProgress.baseline.medianBpm)} BPM`} · qualité médiane {baselineProgress?.baseline === null || baselineProgress?.baseline === undefined ? '—' : `${Math.round(baselineProgress.baseline.medianSignalQuality * 100)} %`}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.eyebrow}>BASELINE APPLE WATCH · SÉPARÉE</Text>
|
||||
<Text style={styles.sectionTitle}>{watchBaseline?.validMeasurementCount ?? 0} mesures comparables</Text>
|
||||
<Text style={styles.detailText}>{watchBaseline === null ? 'Baseline en construction.' : `${watchBaseline.status === 'personalized' ? 'Personnalisée' : watchBaseline.status === 'provisional' ? 'Provisoire' : 'En construction'} · pouls médian ${Math.round(watchBaseline.medianBpm)} BPM`}</Text>
|
||||
</View>
|
||||
|
||||
{correlations.length > 0 ? (
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.eyebrow}>RESSENTI ET MESURE</Text>
|
||||
<Text style={styles.sectionTitle}>Corrélation exploratoire</Text>
|
||||
{correlations.map(point => <Text key={point.feeling} style={styles.detailText}>Ressenti {point.feeling}/5 · {formatMetric(point.averageBpm, 'BPM')} · RMSSD {formatMetric(point.averageRmssdMs, 'ms')} · n={point.count}</Text>)}
|
||||
<Text style={styles.caption}>Association descriptive uniquement, pas une relation médicale ni causale.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{completedRegulations.length > 0 ? <Text style={styles.sectionTitle}>Séances de régulation</Text> : null}
|
||||
{completedRegulations.map(({session, comparison}) => (
|
||||
<View key={session.id} style={styles.regulationCard}>
|
||||
<Text style={styles.eyebrow}>{getRegulationProtocol(session.protocolKind).title}</Text>
|
||||
<Text style={styles.measurementTitle}>{comparison.bpm === null ? 'Pouls non comparable' : `${Math.round(comparison.bpm.before)} → ${Math.round(comparison.bpm.after)} BPM`}</Text>
|
||||
<Text style={styles.detailText}>{comparison.rmssdMs === null ? 'PRV non comparable' : `RMSSD ${Math.round(comparison.rmssdMs.before)} → ${Math.round(comparison.rmssdMs.after)} ms`}</Text>
|
||||
</View>
|
||||
))}
|
||||
{completedWatchRegulations.map(({session, comparison}) => <View key={session.id} style={styles.regulationCard}><Text style={styles.eyebrow}>{session.protocolId} · APPLE WATCH</Text><Text style={styles.measurementTitle}>{Math.round(comparison.bpm.before)} → {Math.round(comparison.bpm.after)} BPM</Text><Text style={styles.detailText}>Qualité {Math.round(comparison.quality.before * 100)} → {Math.round(comparison.quality.after * 100)} % · PRV indisponible sur cette source</Text></View>)}
|
||||
|
||||
<Text style={styles.sectionTitle}>Toutes les mesures</Text>
|
||||
{filtered.length === 0 && filteredWatch.length === 0 ? <Text style={styles.subtitle}>Aucune mesure pour ce filtre.</Text> : filtered.map(measurement => (
|
||||
<Pressable key={measurement.id} onPress={() => setSelectedId(measurement.id)} style={styles.measurementRow} accessibilityRole="button">
|
||||
<View style={styles.row}><Text style={styles.date}>{new Date(measurement.measuredAt).toLocaleString('fr-FR', {day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit'})}</Text><Text style={styles.cameraTag}>{measurement.acquisition.cameraPosition === 'front' ? 'Avant' : measurement.acquisition.cameraPosition === 'back' ? 'Arrière' : 'Caméra ?'}</Text></View>
|
||||
<View style={styles.row}><Text style={styles.measurementTitle}>{formatMetric(measurement.cameraResult.bpm.bpm, 'BPM')}</Text><Text style={styles.measurementValue}>RMSSD {formatMetric(measurement.cameraResult.prv.estimatedRmssdMs, 'ms')}</Text></View>
|
||||
<Text style={styles.caption}>Qualité {Math.round(measurement.cameraResult.quality.score * 100)} % · {measurement.potentialStress.index === null ? 'baseline en construction' : `indice ${measurement.potentialStress.index}/100`} · toucher pour le détail</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{filteredWatch.map(measurement => <Pressable key={`watch-${measurement.id}`} onPress={() => {setSelectedId(null); setSelectedWatchId(measurement.id);}} style={styles.measurementRow} accessibilityRole="button"><View style={styles.row}><Text style={styles.date}>{new Date(measurement.endedAt).toLocaleString('fr-FR', {day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit'})}</Text><Text style={styles.cameraTag}>APPLE WATCH</Text></View><View style={styles.row}><Text style={styles.measurementTitle}>{formatMetric(measurement.averageBpm, 'BPM')}</Text><Text style={styles.measurementValue}>Qualité {Math.round(measurement.quality * 100)} %</Text></View><Text style={styles.caption}>{measurement.device.name} · couverture {Math.round(measurement.coverage * 100)} % · PRV non disponible · toucher pour le détail</Text></Pressable>)}
|
||||
|
||||
<View style={styles.dataCard}>
|
||||
<Text style={styles.sectionTitle}>Gestion des données</Text>
|
||||
<Text style={styles.detailText}>Conservation automatique</Text>
|
||||
<View style={styles.policyWrap}>{([
|
||||
['30days', '30 jours'], ['90days', '90 jours'], ['1year', '1 an'], ['forever', 'Sans limite'],
|
||||
] as const).map(([policy, label]) => <Pressable key={policy} disabled={busy} onPress={() => onRetentionPolicyChange(policy)} style={[styles.policy, retentionPolicy === policy && styles.filterActive]}><Text style={[styles.filterText, retentionPolicy === policy && styles.filterTextActive]}>{label}</Text></Pressable>)}</View>
|
||||
<View style={styles.exportRow}><PrimaryButton label="Exporter JSON" variant="secondary" onPress={() => shareExport('json')} /><PrimaryButton label="Exporter CSV" variant="secondary" onPress={() => shareExport('csv')} /></View>
|
||||
<PrimaryButton label="Effacer toutes les données de mesure" variant="ghost" onPress={confirmClear} disabled={busy || measurements.length === 0} />
|
||||
</View>
|
||||
<PrimaryButton label="Retour" variant="secondary" onPress={onBack} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {padding: spacing.lg, paddingBottom: spacing.xxl, gap: spacing.md},
|
||||
eyebrow: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.3},
|
||||
title: {color: colors.text, fontSize: 38, fontWeight: '800', letterSpacing: -1.2},
|
||||
subtitle: {color: colors.muted, lineHeight: 21},
|
||||
sectionTitle: {color: colors.text, fontSize: 20, fontWeight: '800'},
|
||||
row: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: spacing.sm},
|
||||
filterRow: {flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm},
|
||||
filter: {minWidth: '29%', flexGrow: 1, padding: spacing.sm, borderWidth: 1, borderColor: colors.border, borderRadius: radii.pill, alignItems: 'center'},
|
||||
filterActive: {backgroundColor: colors.accentSoft, borderColor: colors.accent},
|
||||
filterText: {color: colors.muted, fontWeight: '700', fontSize: 12},
|
||||
filterTextActive: {color: colors.text},
|
||||
summaryCard: {padding: spacing.lg, gap: spacing.sm, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface},
|
||||
chartTitle: {color: colors.muted, fontSize: 11, fontWeight: '700', marginTop: spacing.xs},
|
||||
chart: {height: 64, flexDirection: 'row', alignItems: 'flex-end', gap: 4, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border},
|
||||
barSlot: {flex: 1, height: 64, justifyContent: 'flex-end'},
|
||||
bar: {width: '100%', minWidth: 3, borderTopLeftRadius: 3, borderTopRightRadius: 3},
|
||||
caption: {color: colors.muted, fontSize: 10, lineHeight: 15},
|
||||
detailCard: {padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.accent, borderRadius: radii.lg, backgroundColor: colors.surfaceElevated},
|
||||
detailTitle: {color: colors.text, fontSize: 16, fontWeight: '800', marginTop: 4},
|
||||
close: {color: colors.accent, fontWeight: '700'},
|
||||
detailGrid: {flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm},
|
||||
metricTile: {width: '47%', padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surface},
|
||||
metricLabel: {color: colors.muted, fontSize: 10},
|
||||
metricValue: {color: colors.text, fontSize: 18, fontWeight: '800', marginTop: 4},
|
||||
detailText: {color: colors.muted, lineHeight: 20},
|
||||
regulationCard: {padding: spacing.md, gap: spacing.xs, borderWidth: 1, borderColor: colors.good, borderRadius: radii.md, backgroundColor: colors.surface},
|
||||
measurementRow: {paddingVertical: spacing.md, gap: spacing.sm, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border},
|
||||
date: {color: colors.text, fontWeight: '600'},
|
||||
cameraTag: {color: colors.accent, fontSize: 10, fontWeight: '800'},
|
||||
measurementTitle: {color: colors.text, fontSize: 21, fontWeight: '800'},
|
||||
measurementValue: {color: colors.good, fontWeight: '700'},
|
||||
dataCard: {padding: spacing.lg, gap: spacing.md, borderRadius: radii.lg, backgroundColor: colors.surfaceElevated},
|
||||
policyWrap: {flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm},
|
||||
policy: {paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderWidth: 1, borderColor: colors.border, borderRadius: radii.pill},
|
||||
exportRow: {gap: spacing.sm},
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {ActivityIndicator, Pressable, StyleSheet, Text, View} from 'react-native';
|
||||
import {StopCircle} from 'phosphor-react-native';
|
||||
import type {PPGFrameUpdate} from 'react-native-haptika-ppg';
|
||||
|
||||
import {colors} from '../../../theme';
|
||||
import {PPGCameraView} from '../camera/PPGCameraView';
|
||||
import type {PPGCameraError} from '../camera/cameraTypes';
|
||||
import {HaptikaHeader} from '../components/HaptikaChrome';
|
||||
import type {MeasurementState} from '../measurement/measurementTypes';
|
||||
|
||||
interface PPGMeasurementScreenProps {
|
||||
state: MeasurementState;
|
||||
latestFrame: PPGFrameUpdate | null;
|
||||
phaseProgress: number;
|
||||
isCameraActive: boolean;
|
||||
torchEnabled: boolean;
|
||||
cameraPosition: 'back' | 'front';
|
||||
onCameraStarted: () => void;
|
||||
onFrameUpdate: (update: PPGFrameUpdate) => void;
|
||||
onInterrupted: (reason: string) => void;
|
||||
onCameraError: (error: PPGCameraError) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const BAR_COUNT = 5;
|
||||
|
||||
function statusCopy(state: MeasurementState): {title: string; subtitle: string} {
|
||||
if (state === 'processing') {
|
||||
return {
|
||||
title: 'Analyse en cours',
|
||||
subtitle: 'Nous préparons votre résultat',
|
||||
};
|
||||
}
|
||||
if (state === 'preparingCamera') {
|
||||
return {
|
||||
title: 'Initialisation',
|
||||
subtitle: 'Gardez votre doigt en place',
|
||||
};
|
||||
}
|
||||
if (state === 'waitingForFinger') {
|
||||
return {
|
||||
title: 'Recherche du signal',
|
||||
subtitle: 'Posez doucement votre doigt',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Mesure en cours',
|
||||
subtitle: 'Ne bougez pas et respirez\nnaturellement',
|
||||
};
|
||||
}
|
||||
|
||||
function qualityLabel(score: number): string {
|
||||
if (score >= 0.78) return 'EXCELLENT';
|
||||
if (score >= 0.55) return 'BON';
|
||||
if (score >= 0.32) return 'MOYEN';
|
||||
return 'EN COURS';
|
||||
}
|
||||
|
||||
export function PPGMeasurementScreen({
|
||||
state,
|
||||
latestFrame,
|
||||
phaseProgress,
|
||||
isCameraActive,
|
||||
torchEnabled,
|
||||
cameraPosition,
|
||||
onCameraStarted,
|
||||
onFrameUpdate,
|
||||
onInterrupted,
|
||||
onCameraError,
|
||||
onCancel,
|
||||
}: PPGMeasurementScreenProps): React.JSX.Element {
|
||||
const [pulse, setPulse] = useState(0);
|
||||
const isProcessing = state === 'processing';
|
||||
const progress = Math.min(Math.max(phaseProgress, 0), 1);
|
||||
const displayedProgress = Math.round(progress * 100);
|
||||
const confidence = Math.min(
|
||||
Math.max(latestFrame?.contactConfidence ?? (state === 'measuring' ? 0.8 : 0.25), 0),
|
||||
1,
|
||||
);
|
||||
const copy = statusCopy(state);
|
||||
const activeDots = Math.max(1, Math.round(confidence * 5));
|
||||
const barHeights = useMemo(
|
||||
() => [13, 23, 18, 28, 20].map((height, index) => height - ((pulse + index) % 3) * 2),
|
||||
[pulse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setPulse(value => value + 1), 420);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<PPGCameraView
|
||||
isActive={isCameraActive}
|
||||
cameraPosition={cameraPosition}
|
||||
torchEnabled={torchEnabled}
|
||||
lockOptics={
|
||||
(state === 'stabilizing' || state === 'measuring') &&
|
||||
latestFrame?.contactState === 'valid'
|
||||
}
|
||||
onCameraStarted={onCameraStarted}
|
||||
onFrameUpdate={onFrameUpdate}
|
||||
onInterrupted={onInterrupted}
|
||||
onError={onCameraError}
|
||||
/>
|
||||
<View style={styles.opaqueCanvas} />
|
||||
<HaptikaHeader onMenuPress={onCancel} />
|
||||
|
||||
<View style={styles.measurementMain}>
|
||||
<View style={styles.headingBlock}>
|
||||
<View style={styles.liveRow}>
|
||||
<View style={styles.liveDot} />
|
||||
<Text style={styles.liveLabel}>ENREGISTREMENT DIRECT</Text>
|
||||
</View>
|
||||
<Text style={styles.measurementTitle}>{copy.title}</Text>
|
||||
<Text style={styles.measurementSubtitle}>{copy.subtitle}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.signalHalo}>
|
||||
<View style={styles.signalCircle}>
|
||||
{isProcessing ? (
|
||||
<ActivityIndicator color={colors.accent} size="small" />
|
||||
) : (
|
||||
<View style={styles.liveBars}>
|
||||
{barHeights.map((height, index) => (
|
||||
<View key={index} style={[styles.liveBar, {height}]} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.controls}>
|
||||
<View style={styles.qualityPill}>
|
||||
<Text style={styles.signalLabel}>SIGNAL</Text>
|
||||
<View style={styles.qualityDots}>
|
||||
{Array.from({length: BAR_COUNT}, (_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.qualityDot,
|
||||
index >= activeDots && styles.qualityDotInactive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.qualityText}>{qualityLabel(confidence)}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.stopSection}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={isProcessing}
|
||||
onPress={onCancel}
|
||||
style={({pressed}) => [
|
||||
styles.stopButton,
|
||||
pressed && styles.pressed,
|
||||
isProcessing && styles.disabled,
|
||||
]}>
|
||||
<StopCircle color={colors.accent} size={22} weight="fill" />
|
||||
<Text style={styles.stopText}>
|
||||
{isProcessing ? 'Analyse' : 'Arrêter'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={onCancel} style={styles.cancelButton}>
|
||||
<Text style={styles.cancelText}>Annuler</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBlock}>
|
||||
<View style={styles.progressLabels}>
|
||||
<Text style={styles.progressLabel}>PROGRESSION DE L’ANALYSE</Text>
|
||||
<Text style={styles.progressValue}>{displayedProgress}%</Text>
|
||||
</View>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, {width: `${displayedProgress}%`}]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {flex: 1, backgroundColor: '#071321'},
|
||||
opaqueCanvas: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: '#071321',
|
||||
},
|
||||
measurementMain: {height: 496, alignItems: 'center'},
|
||||
headingBlock: {alignItems: 'center', paddingTop: 32},
|
||||
liveRow: {height: 22, flexDirection: 'row', alignItems: 'center'},
|
||||
liveDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#7DE2FF',
|
||||
shadowColor: colors.accent,
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 7,
|
||||
},
|
||||
liveLabel: {
|
||||
color: colors.accent,
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 2.1,
|
||||
marginLeft: 8,
|
||||
},
|
||||
measurementTitle: {
|
||||
color: '#DFE6F1',
|
||||
fontSize: 23,
|
||||
lineHeight: 28,
|
||||
fontWeight: '400',
|
||||
marginTop: 8,
|
||||
},
|
||||
measurementSubtitle: {
|
||||
color: '#A6AFBE',
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
marginTop: 6,
|
||||
},
|
||||
signalHalo: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
borderRadius: 128,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(27,74,107,0.20)',
|
||||
marginTop: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
signalCircle: {
|
||||
width: 224,
|
||||
height: 224,
|
||||
borderRadius: 112,
|
||||
borderWidth: 1,
|
||||
borderColor: '#234D6B',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
liveBars: {height: 32, flexDirection: 'row', alignItems: 'flex-end', gap: 4},
|
||||
liveBar: {width: 4, borderRadius: 2, backgroundColor: '#62D4FF'},
|
||||
controls: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 38,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
qualityPill: {
|
||||
width: 230,
|
||||
height: 33,
|
||||
borderRadius: 17,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(23,70,111,0.7)',
|
||||
backgroundColor: 'rgba(5,19,34,0.85)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
signalLabel: {color: '#7F8A9A', fontSize: 10, letterSpacing: 1.1},
|
||||
qualityDots: {flexDirection: 'row', gap: 6, marginHorizontal: 18},
|
||||
qualityDot: {width: 6, height: 6, borderRadius: 3, backgroundColor: colors.accent},
|
||||
qualityDotInactive: {backgroundColor: '#24465E'},
|
||||
qualityText: {color: colors.accent, fontSize: 10, fontWeight: '800'},
|
||||
stopSection: {height: 140, alignItems: 'center', paddingTop: 40},
|
||||
stopButton: {
|
||||
width: 165,
|
||||
height: 56,
|
||||
borderRadius: 13,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1D3B58',
|
||||
backgroundColor: '#142236',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
stopText: {color: '#CDD5E1', fontSize: 16},
|
||||
cancelButton: {height: 40, alignItems: 'center', justifyContent: 'center'},
|
||||
cancelText: {color: '#D0D7E1', fontSize: 12},
|
||||
progressBlock: {width: '100%', marginTop: 'auto'},
|
||||
progressLabels: {
|
||||
height: 24,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
progressLabel: {color: '#526070', fontSize: 9, letterSpacing: 1.2},
|
||||
progressValue: {
|
||||
color: '#59BDEB',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
progressTrack: {height: 2, backgroundColor: '#10283A', overflow: 'hidden'},
|
||||
progressFill: {height: 2, backgroundColor: '#62D4FF'},
|
||||
pressed: {opacity: 0.76, transform: [{scale: 0.99}]},
|
||||
disabled: {opacity: 0.55},
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
import React, {useState} from 'react';
|
||||
import {
|
||||
Image,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Armchair,
|
||||
Camera,
|
||||
Hand,
|
||||
MinusCircle,
|
||||
WarningCircle,
|
||||
Wind,
|
||||
X,
|
||||
} from 'phosphor-react-native';
|
||||
|
||||
import {colors, radii, spacing} from '../../../theme';
|
||||
import {
|
||||
BottomNavigation,
|
||||
HaptikaHeader,
|
||||
} from '../components/HaptikaChrome';
|
||||
import type {BaselineCalculationResult} from '../stress/baselineTypes';
|
||||
|
||||
const placementImage = require('../assets/finger-camera-placement.png');
|
||||
const silhouetteImage = require('../assets/hrv-silhouette.png');
|
||||
|
||||
interface PPGPreparationScreenProps {
|
||||
includeHealthKit: boolean;
|
||||
baselineProgress: BaselineCalculationResult | null;
|
||||
postExerciseProtocolTitle: string | null;
|
||||
onIncludeHealthKitChange: (value: boolean) => void;
|
||||
sensitiveContact: boolean;
|
||||
onSensitiveContactChange: (value: boolean) => void;
|
||||
cameraPosition: 'back' | 'front';
|
||||
onCameraPositionChange: (value: 'back' | 'front') => void;
|
||||
atRest: boolean | null;
|
||||
onAtRestChange: (value: boolean) => void;
|
||||
onStart: (atRestOverride?: boolean) => void;
|
||||
onOpenHistory: () => void;
|
||||
onOpenCardiacMirror: () => void;
|
||||
onOpenHapticExercise: () => void;
|
||||
isStarting: boolean;
|
||||
}
|
||||
|
||||
type InstructionIcon = React.ComponentType<{
|
||||
color?: string;
|
||||
size?: number;
|
||||
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone';
|
||||
}>;
|
||||
|
||||
const instructions: readonly {label: string; icon: InstructionIcon}[] = [
|
||||
{label: 'Asseyez-vous confortablement', icon: Armchair},
|
||||
{label: 'Posez doucement votre doigt', icon: Hand},
|
||||
{label: 'Gardez la main immobile', icon: MinusCircle},
|
||||
{label: 'Respirez naturellement', icon: Wind},
|
||||
];
|
||||
|
||||
export function PPGPreparationScreen({
|
||||
includeHealthKit,
|
||||
postExerciseProtocolTitle,
|
||||
onIncludeHealthKitChange,
|
||||
sensitiveContact,
|
||||
onSensitiveContactChange,
|
||||
cameraPosition,
|
||||
onCameraPositionChange,
|
||||
onAtRestChange,
|
||||
onStart,
|
||||
onOpenHistory,
|
||||
onOpenCardiacMirror,
|
||||
onOpenHapticExercise,
|
||||
isStarting,
|
||||
}: PPGPreparationScreenProps): React.JSX.Element {
|
||||
const [screen, setScreen] = useState<'home' | 'preparation'>('home');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const openPreparation = (): void => {
|
||||
onAtRestChange(true);
|
||||
setScreen('preparation');
|
||||
};
|
||||
|
||||
const beginMeasurement = (): void => {
|
||||
onAtRestChange(true);
|
||||
onStart(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<HaptikaHeader
|
||||
onMenuPress={screen === 'home' ? onOpenHistory : () => setScreen('home')}
|
||||
onSettingsPress={() => setSettingsOpen(true)}
|
||||
/>
|
||||
|
||||
{screen === 'home' ? (
|
||||
<>
|
||||
<ScrollView
|
||||
style={styles.scrollArea}
|
||||
contentContainerStyle={styles.homeContent}
|
||||
showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.homeIntro}>
|
||||
<Text style={styles.homeKicker}>MESURE RAPIDE</Text>
|
||||
<Text style={styles.homeTitle}>Lisez votre VFC à la demande</Text>
|
||||
<Text style={styles.homeSubtitle}>
|
||||
Découvrez l’état de votre système{`\n`}nerveux autonome en temps réel.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.axisBlock}>
|
||||
<View style={styles.axisLabels}>
|
||||
<Text style={styles.sympathetic}>SYMPATHIQUE</Text>
|
||||
<Text style={styles.parasympathetic}>PARASYMPATHIQUE</Text>
|
||||
</View>
|
||||
<View style={styles.axisTrack}>
|
||||
<View style={styles.axisCursor} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Image
|
||||
accessibilityLabel="Silhouette indiquant un équilibre favorable"
|
||||
resizeMode="contain"
|
||||
source={silhouetteImage}
|
||||
style={styles.silhouette}
|
||||
/>
|
||||
|
||||
{postExerciseProtocolTitle !== null ? (
|
||||
<View style={styles.afterExerciseBanner}>
|
||||
<Text style={styles.afterExerciseLabel}>MESURE APRÈS EXERCICE</Text>
|
||||
<Text style={styles.afterExerciseText}>
|
||||
Comparez maintenant l’effet de {postExerciseProtocolTitle}.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Mesurer ma VFC"
|
||||
onPress={openPreparation}
|
||||
style={({pressed}) => [styles.measureCard, pressed && styles.pressed]}>
|
||||
<View style={styles.cameraBadge}>
|
||||
<Camera color={colors.accent} size={21} weight="regular" />
|
||||
</View>
|
||||
<View style={styles.measureCopy}>
|
||||
<Text style={styles.measureTitle}>Mesurer ma VFC</Text>
|
||||
<Text style={styles.measureSubtitle}>
|
||||
Placez votre doigt sur la caméra avant
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>60S</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={styles.definition}>
|
||||
La variabilité de la fréquence cardiaque (VFC) est un indicateur
|
||||
clé de votre capacité de récupération et de gestion du stress.
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={isStarting}
|
||||
onPress={openPreparation}
|
||||
style={({pressed}) => [styles.mainCta, pressed && styles.pressed]}>
|
||||
<Text style={styles.mainCtaText}>COMMENCER LA MESURE</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
<BottomNavigation
|
||||
onHistory={onOpenHistory}
|
||||
onSos={onOpenCardiacMirror}
|
||||
onTools={onOpenHapticExercise}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScrollView
|
||||
style={styles.scrollArea}
|
||||
contentContainerStyle={styles.preparationContent}
|
||||
showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.preparationHeading}>
|
||||
<Text style={styles.preparationTitle}>Préparez-vous</Text>
|
||||
<Text style={styles.preparationSubtitle}>
|
||||
PHASE DE CALIBRAGE BIOMÉTRIQUE
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.placementHalo}>
|
||||
<Image
|
||||
accessibilityLabel="Doigt posé doucement sur la caméra de l’iPhone"
|
||||
source={placementImage}
|
||||
style={styles.placementImage}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.instructionList}>
|
||||
{instructions.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<View key={item.label} style={styles.instructionRow}>
|
||||
<View style={styles.instructionIcon}>
|
||||
<Icon color={colors.accent} size={17} weight="regular" />
|
||||
</View>
|
||||
<Text style={styles.instructionText}>{item.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={styles.motionWarning}>
|
||||
<WarningCircle color="#EE8294" size={14} weight="regular" />
|
||||
<Text style={styles.motionWarningText}>
|
||||
Tout mouvement peut réduire la qualité du signal.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{cameraPosition === 'back' ? (
|
||||
<Text style={styles.thermalWarning}>
|
||||
Le flash peut chauffer légèrement. Retirez votre doigt si la
|
||||
chaleur devient inconfortable.
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.preparationFooter}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={isStarting}
|
||||
onPress={beginMeasurement}
|
||||
style={({pressed}) => [styles.readyButton, pressed && styles.pressed]}>
|
||||
<Text style={styles.readyButtonText}>
|
||||
{isStarting ? 'PRÉPARATION…' : 'JE SUIS PRÊT'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => setScreen('home')}
|
||||
style={styles.cancelButton}>
|
||||
<Text style={styles.cancelText}>Annuler</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsModal
|
||||
visible={settingsOpen}
|
||||
includeHealthKit={includeHealthKit}
|
||||
sensitiveContact={sensitiveContact}
|
||||
cameraPosition={cameraPosition}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onIncludeHealthKitChange={onIncludeHealthKitChange}
|
||||
onSensitiveContactChange={onSensitiveContactChange}
|
||||
onCameraPositionChange={onCameraPositionChange}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsModal({
|
||||
visible,
|
||||
includeHealthKit,
|
||||
sensitiveContact,
|
||||
cameraPosition,
|
||||
onClose,
|
||||
onIncludeHealthKitChange,
|
||||
onSensitiveContactChange,
|
||||
onCameraPositionChange,
|
||||
}: {
|
||||
visible: boolean;
|
||||
includeHealthKit: boolean;
|
||||
sensitiveContact: boolean;
|
||||
cameraPosition: 'back' | 'front';
|
||||
onClose: () => void;
|
||||
onIncludeHealthKitChange: (value: boolean) => void;
|
||||
onSensitiveContactChange: (value: boolean) => void;
|
||||
onCameraPositionChange: (value: 'back' | 'front') => void;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Modal animationType="slide" transparent visible={visible} onRequestClose={onClose}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={onClose}>
|
||||
<Pressable style={styles.settingsSheet} onPress={() => undefined}>
|
||||
<View style={styles.settingsHeader}>
|
||||
<View>
|
||||
<Text style={styles.settingsKicker}>MESURE VFC</Text>
|
||||
<Text style={styles.settingsTitle}>Réglages</Text>
|
||||
</View>
|
||||
<Pressable accessibilityLabel="Fermer" onPress={onClose}>
|
||||
<X color={colors.text} size={24} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.settingLabel}>Caméra utilisée</Text>
|
||||
<View style={styles.cameraSegments}>
|
||||
{(['front', 'back'] as const).map(position => (
|
||||
<Pressable
|
||||
key={position}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{checked: cameraPosition === position}}
|
||||
onPress={() => onCameraPositionChange(position)}
|
||||
style={[
|
||||
styles.cameraSegment,
|
||||
cameraPosition === position && styles.cameraSegmentSelected,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.cameraSegmentText,
|
||||
cameraPosition === position && styles.cameraSegmentTextSelected,
|
||||
]}>
|
||||
{position === 'front' ? 'Avant' : 'Arrière + flash'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<SettingSwitch
|
||||
label="Contact sensible"
|
||||
description="Tolère davantage les micro-mouvements."
|
||||
value={sensitiveContact}
|
||||
onValueChange={onSensitiveContactChange}
|
||||
/>
|
||||
<SettingSwitch
|
||||
label="Apple Santé"
|
||||
description="Contexte historique facultatif, en lecture seule."
|
||||
value={includeHealthKit}
|
||||
onValueChange={onIncludeHealthKitChange}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingSwitch({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingCopy}>
|
||||
<Text style={styles.settingTitle}>{label}</Text>
|
||||
<Text style={styles.settingDescription}>{description}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{false: '#213145', true: '#135879'}}
|
||||
thumbColor={value ? colors.accent : '#9BA8B6'}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {flex: 1, backgroundColor: '#071321'},
|
||||
scrollArea: {flex: 1},
|
||||
homeContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
homeIntro: {width: '100%', alignItems: 'center'},
|
||||
homeKicker: {
|
||||
color: colors.accent,
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.25,
|
||||
},
|
||||
homeTitle: {
|
||||
marginTop: 8,
|
||||
color: '#E0E7F2',
|
||||
fontSize: 23,
|
||||
lineHeight: 28,
|
||||
fontWeight: '400',
|
||||
textAlign: 'center',
|
||||
},
|
||||
homeSubtitle: {
|
||||
marginTop: 10,
|
||||
color: '#B7C0CE',
|
||||
fontSize: 16,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
axisBlock: {width: 320, marginTop: 34},
|
||||
axisLabels: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
sympathetic: {
|
||||
color: '#F05869',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.25,
|
||||
},
|
||||
parasympathetic: {
|
||||
color: '#3187B8',
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.25,
|
||||
},
|
||||
axisTrack: {
|
||||
marginTop: 16,
|
||||
marginHorizontal: 16,
|
||||
height: 4,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#63D6FF',
|
||||
borderLeftWidth: 96,
|
||||
borderLeftColor: '#F55B68',
|
||||
},
|
||||
axisCursor: {
|
||||
position: 'absolute',
|
||||
left: '64%',
|
||||
top: -7,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: '#EDF4FC',
|
||||
borderWidth: 2,
|
||||
borderColor: '#162435',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.45,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
silhouette: {width: 280, height: 330, marginTop: 8},
|
||||
afterExerciseBanner: {
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.good,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
marginBottom: 10,
|
||||
},
|
||||
afterExerciseLabel: {
|
||||
color: colors.good,
|
||||
fontSize: 9,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
afterExerciseText: {color: colors.text, fontSize: 12, marginTop: 3},
|
||||
measureCard: {
|
||||
width: '100%',
|
||||
minHeight: 72,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#17466F',
|
||||
backgroundColor: 'rgba(7,28,46,0.96)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
cameraBadge: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 7,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#10314B',
|
||||
},
|
||||
measureCopy: {flex: 1, marginLeft: 16},
|
||||
measureTitle: {color: '#DCE4F1', fontSize: 17, fontWeight: '700'},
|
||||
measureSubtitle: {color: '#B2BDCA', fontSize: 12, marginTop: 2},
|
||||
durationBadge: {
|
||||
minWidth: 30,
|
||||
height: 20,
|
||||
borderRadius: 5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#12314A',
|
||||
},
|
||||
durationText: {color: colors.accent, fontSize: 10, fontWeight: '800'},
|
||||
definition: {
|
||||
width: '100%',
|
||||
color: '#8997A9',
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
fontStyle: 'italic',
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 12,
|
||||
},
|
||||
mainCta: {
|
||||
width: '100%',
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
backgroundColor: '#75D3FA',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 28,
|
||||
},
|
||||
mainCtaText: {
|
||||
color: '#071321',
|
||||
fontSize: 10,
|
||||
fontWeight: '900',
|
||||
letterSpacing: 2.25,
|
||||
},
|
||||
preparationContent: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 156,
|
||||
alignItems: 'center',
|
||||
},
|
||||
preparationHeading: {alignItems: 'center'},
|
||||
preparationTitle: {
|
||||
color: '#DBE4F0',
|
||||
fontSize: 29,
|
||||
lineHeight: 34,
|
||||
fontWeight: '300',
|
||||
},
|
||||
preparationSubtitle: {
|
||||
color: '#8F9AA9',
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.4,
|
||||
marginTop: 8,
|
||||
},
|
||||
placementHalo: {
|
||||
width: 280,
|
||||
height: 280,
|
||||
marginTop: 32,
|
||||
borderRadius: 140,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(28,82,119,0.20)',
|
||||
shadowColor: colors.accent,
|
||||
shadowOpacity: 0.13,
|
||||
shadowRadius: 22,
|
||||
},
|
||||
placementImage: {width: 256, height: 256, borderRadius: 128},
|
||||
instructionList: {width: '100%', gap: 12, marginTop: 32},
|
||||
instructionRow: {
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#17466F',
|
||||
backgroundColor: 'rgba(6,24,41,0.96)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
instructionIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#0D2D47',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
instructionText: {color: '#D5DEEB', fontSize: 15, marginLeft: 16},
|
||||
motionWarning: {
|
||||
minHeight: 42,
|
||||
alignSelf: 'stretch',
|
||||
marginHorizontal: 13,
|
||||
marginTop: 20,
|
||||
borderRadius: 7,
|
||||
borderWidth: 1,
|
||||
borderColor: '#603246',
|
||||
backgroundColor: '#1D131E',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
},
|
||||
motionWarningText: {color: '#C3A7B2', fontSize: 12, flex: 1},
|
||||
thermalWarning: {
|
||||
color: '#D7B2A0',
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
marginTop: 10,
|
||||
textAlign: 'center',
|
||||
},
|
||||
preparationFooter: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 100,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 8,
|
||||
backgroundColor: '#071321',
|
||||
},
|
||||
readyButton: {
|
||||
height: 52,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#75D3FA',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.accent,
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 14,
|
||||
shadowOffset: {width: 0, height: 8},
|
||||
},
|
||||
readyButtonText: {
|
||||
color: '#071321',
|
||||
fontSize: 10,
|
||||
fontWeight: '900',
|
||||
letterSpacing: 2.2,
|
||||
},
|
||||
cancelButton: {height: 40, alignItems: 'center', justifyContent: 'center'},
|
||||
cancelText: {color: '#D3DCE8', fontSize: 12},
|
||||
pressed: {opacity: 0.76, transform: [{scale: 0.99}]},
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0,4,12,0.72)',
|
||||
},
|
||||
settingsSheet: {
|
||||
borderTopLeftRadius: radii.lg,
|
||||
borderTopRightRadius: radii.lg,
|
||||
backgroundColor: '#0A1A2B',
|
||||
borderTopWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.lg,
|
||||
paddingBottom: 42,
|
||||
},
|
||||
settingsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
settingsKicker: {
|
||||
color: colors.accent,
|
||||
fontSize: 9,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
settingsTitle: {color: colors.text, fontSize: 27, fontWeight: '600'},
|
||||
settingLabel: {color: colors.muted, fontSize: 12, marginBottom: 8},
|
||||
cameraSegments: {flexDirection: 'row', gap: 8, marginBottom: 16},
|
||||
cameraSegment: {
|
||||
flex: 1,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cameraSegmentSelected: {backgroundColor: '#123B58', borderColor: colors.accent},
|
||||
cameraSegmentText: {color: colors.muted, fontWeight: '700'},
|
||||
cameraSegmentTextSelected: {color: colors.text},
|
||||
settingRow: {
|
||||
minHeight: 72,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
settingCopy: {flex: 1, paddingRight: 12},
|
||||
settingTitle: {color: colors.text, fontWeight: '700'},
|
||||
settingDescription: {color: colors.muted, fontSize: 12, marginTop: 3},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export interface AcquisitionPreferences {
|
||||
cameraPosition: 'back' | 'front';
|
||||
sensitiveContact: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = '@haptika/ppg/v1/acquisition-preferences';
|
||||
|
||||
export const DEFAULT_ACQUISITION_PREFERENCES: Readonly<AcquisitionPreferences> =
|
||||
Object.freeze({
|
||||
cameraPosition: 'front',
|
||||
sensitiveContact: true,
|
||||
});
|
||||
|
||||
function parsePreferences(value: string | null): AcquisitionPreferences {
|
||||
if (value === null) return {...DEFAULT_ACQUISITION_PREFERENCES};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<AcquisitionPreferences>;
|
||||
return {
|
||||
cameraPosition:
|
||||
parsed.cameraPosition === 'back' || parsed.cameraPosition === 'front'
|
||||
? parsed.cameraPosition
|
||||
: DEFAULT_ACQUISITION_PREFERENCES.cameraPosition,
|
||||
sensitiveContact:
|
||||
typeof parsed.sensitiveContact === 'boolean'
|
||||
? parsed.sensitiveContact
|
||||
: DEFAULT_ACQUISITION_PREFERENCES.sensitiveContact,
|
||||
};
|
||||
} catch {
|
||||
return {...DEFAULT_ACQUISITION_PREFERENCES};
|
||||
}
|
||||
}
|
||||
|
||||
export const acquisitionPreferencesRepository = {
|
||||
async get(): Promise<AcquisitionPreferences> {
|
||||
return parsePreferences(await AsyncStorage.getItem(STORAGE_KEY));
|
||||
},
|
||||
|
||||
async set(preferences: AcquisitionPreferences): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
calculateBaseline,
|
||||
DEFAULT_BASELINE_CONFIGURATION,
|
||||
} from '../stress/baselineCalculator';
|
||||
import type {BaselineCalculationResult} from '../stress/baselineTypes';
|
||||
import {measurementRepository, toBaselineMeasurement} from './measurementRepository';
|
||||
|
||||
export const baselineRepository = {
|
||||
async calculate(): Promise<BaselineCalculationResult> {
|
||||
const measurements = await measurementRepository.getAll();
|
||||
return calculateBaseline(
|
||||
measurements.map(toBaselineMeasurement),
|
||||
DEFAULT_BASELINE_CONFIGURATION,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const namespace = '@haptika/ppg/v1';
|
||||
|
||||
export const storageKeys = {
|
||||
measurements: `${namespace}/measurements`,
|
||||
settings: `${namespace}/settings`,
|
||||
} as const;
|
||||
|
||||
export async function readJSON<T>(key: string, fallback: T): Promise<T> {
|
||||
const stored = await AsyncStorage.getItem(key);
|
||||
if (stored === null) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(stored) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJSON<T>(key: string, value: T): Promise<void> {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import type {BaselineMeasurement} from '../stress/baselineTypes';
|
||||
import type {
|
||||
HealthHeartbeatSeriesSummary,
|
||||
HealthKitContext,
|
||||
HealthMetricSample,
|
||||
} from '../health/healthTypes';
|
||||
import type {
|
||||
BPMEstimate,
|
||||
PPGProcessingResult,
|
||||
PRVMetrics,
|
||||
SignalQualityResult,
|
||||
} from '../measurement/measurementTypes';
|
||||
import type {PotentialStressResult} from '../stress/stressTypes';
|
||||
import {readJSON, storageKeys, writeJSON} from './database';
|
||||
import type {
|
||||
DataRetentionPolicy,
|
||||
MeasurementSelfReport,
|
||||
PPGDataPolicy,
|
||||
StoredMeasurement,
|
||||
} from './storageTypes';
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
const isRecord = (value: unknown): value is UnknownRecord =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const isNullableFiniteNumber = (value: unknown): value is number | null =>
|
||||
value === null || isFiniteNumber(value);
|
||||
const isNullableString = (value: unknown): value is string | null =>
|
||||
value === null || typeof value === 'string';
|
||||
const isValidDateString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
|
||||
function isBPMEstimate(value: unknown): value is BPMEstimate {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isNullableFiniteNumber(value.bpm) &&
|
||||
isNullableFiniteNumber(value.temporalBpm) &&
|
||||
isNullableFiniteNumber(value.frequencyBpm) &&
|
||||
isNullableFiniteNumber(value.methodAgreementBpm) &&
|
||||
isFiniteNumber(value.validPeakCount) &&
|
||||
isFiniteNumber(value.rejectedPeakCount) &&
|
||||
isFiniteNumber(value.confidence) &&
|
||||
typeof value.selectedChannel === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isPRVMetrics(value: unknown): value is PRVMetrics {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isNullableFiniteNumber(value.estimatedRmssdMs) &&
|
||||
isNullableFiniteNumber(value.estimatedSdnnMs) &&
|
||||
(value.meanIpiMs === undefined || isNullableFiniteNumber(value.meanIpiMs)) &&
|
||||
isNullableFiniteNumber(value.medianIpiMs) &&
|
||||
(value.pnn50Percent === undefined || isNullableFiniteNumber(value.pnn50Percent)) &&
|
||||
(value.rangeIpiMs === undefined || isNullableFiniteNumber(value.rangeIpiMs)) &&
|
||||
(value.modeIpiMs === undefined || isNullableFiniteNumber(value.modeIpiMs)) &&
|
||||
(value.amo50Percent === undefined || isNullableFiniteNumber(value.amo50Percent)) &&
|
||||
isNullableFiniteNumber(value.coefficientOfVariation) &&
|
||||
isFiniteNumber(value.validIntervalCount) &&
|
||||
isFiniteNumber(value.rejectedIntervalCount) &&
|
||||
isFiniteNumber(value.rejectedIntervalRatio) &&
|
||||
typeof value.reliable === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
function isSignalQualityResult(value: unknown): value is SignalQualityResult {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isFiniteNumber(value.score) &&
|
||||
['good', 'medium', 'poor', 'invalid'].includes(String(value.level)) &&
|
||||
isFiniteNumber(value.usableDurationSeconds) &&
|
||||
isFiniteNumber(value.droppedFrameRatio) &&
|
||||
isFiniteNumber(value.saturationRatio) &&
|
||||
isFiniteNumber(value.rejectedIntervalRatio) &&
|
||||
isNullableFiniteNumber(value.signalToNoiseRatio) &&
|
||||
isFiniteNumber(value.motionArtifactRatio) &&
|
||||
isFiniteNumber(value.methodAgreementScore) &&
|
||||
isFiniteNumber(value.timestampJitterRatio) &&
|
||||
isFiniteNumber(value.longestGapSeconds) &&
|
||||
Array.isArray(value.issues) &&
|
||||
value.issues.every(issue => typeof issue === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function isProcessingResult(value: unknown): value is PPGProcessingResult {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.valid === 'boolean' &&
|
||||
isNullableString(value.invalidReason) &&
|
||||
isBPMEstimate(value.bpm) &&
|
||||
isPRVMetrics(value.prv) &&
|
||||
isSignalQualityResult(value.quality) &&
|
||||
isFiniteNumber(value.startedAtMonotonicSeconds) &&
|
||||
isFiniteNumber(value.endedAtMonotonicSeconds) &&
|
||||
isFiniteNumber(value.rawSampleCount) &&
|
||||
isFiniteNumber(value.resampledSampleCount)
|
||||
);
|
||||
}
|
||||
|
||||
function isPotentialStressResult(value: unknown): value is PotentialStressResult {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isNullableFiniteNumber(value.index) &&
|
||||
isFiniteNumber(value.confidence) &&
|
||||
['unavailable', 'baselineBuilding', 'provisional', 'personalized'].includes(
|
||||
String(value.status),
|
||||
) &&
|
||||
isNullableFiniteNumber(value.bpmContribution) &&
|
||||
isNullableFiniteNumber(value.prvContribution) &&
|
||||
isFiniteNumber(value.signalQuality) &&
|
||||
isFiniteNumber(value.baselineMeasurementCount) &&
|
||||
value.healthKitUsedForScore === false &&
|
||||
Array.isArray(value.limitations) &&
|
||||
value.limitations.every(limitation => typeof limitation === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function isHealthMetricSample(value: unknown): value is HealthMetricSample {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
['heartRate', 'restingHeartRate', 'hrvSdnn'].includes(String(value.type)) &&
|
||||
isFiniteNumber(value.value) &&
|
||||
typeof value.unit === 'string' &&
|
||||
isValidDateString(value.startDate) &&
|
||||
isValidDateString(value.endDate) &&
|
||||
isNullableString(value.sourceName) &&
|
||||
isNullableString(value.sourceBundleIdentifier) &&
|
||||
isNullableString(value.deviceName)
|
||||
);
|
||||
}
|
||||
|
||||
function isHeartbeatSeriesSummary(
|
||||
value: unknown,
|
||||
): value is HealthHeartbeatSeriesSummary {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isValidDateString(value.startDate) &&
|
||||
isValidDateString(value.endDate) &&
|
||||
isNullableString(value.sourceName) &&
|
||||
isNullableString(value.sourceBundleIdentifier) &&
|
||||
Array.isArray(value.heartbeatTimesSinceSeriesStartSeconds) &&
|
||||
value.heartbeatTimesSinceSeriesStartSeconds.every(isFiniteNumber) &&
|
||||
Array.isArray(value.precededByGapIndexes) &&
|
||||
value.precededByGapIndexes.every(isFiniteNumber)
|
||||
);
|
||||
}
|
||||
|
||||
function parseHealthKitContext(value: unknown): HealthKitContext | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const authorizationRequestSettled =
|
||||
typeof value.authorizationRequestSettled === 'boolean'
|
||||
? value.authorizationRequestSettled
|
||||
: typeof value.authorized === 'boolean'
|
||||
? value.authorized
|
||||
: undefined;
|
||||
const valid =
|
||||
typeof value.available === 'boolean' &&
|
||||
authorizationRequestSettled !== undefined &&
|
||||
(value.latestRestingHeartRate === null ||
|
||||
isHealthMetricSample(value.latestRestingHeartRate)) &&
|
||||
(value.latestHrvSdnn === null || isHealthMetricSample(value.latestHrvSdnn)) &&
|
||||
Array.isArray(value.recentHeartRateSamples) &&
|
||||
value.recentHeartRateSamples.every(isHealthMetricSample) &&
|
||||
(value.latestHeartbeatSeries === undefined ||
|
||||
value.latestHeartbeatSeries === null ||
|
||||
isHeartbeatSeriesSummary(value.latestHeartbeatSeries)) &&
|
||||
isFiniteNumber(value.contextConfidence) &&
|
||||
isValidDateString(value.fetchedAt);
|
||||
if (!valid) return undefined;
|
||||
return {
|
||||
available: value.available as boolean,
|
||||
authorizationRequestSettled,
|
||||
latestRestingHeartRate: value.latestRestingHeartRate as HealthMetricSample | null,
|
||||
latestHrvSdnn: value.latestHrvSdnn as HealthMetricSample | null,
|
||||
recentHeartRateSamples: value.recentHeartRateSamples as HealthMetricSample[],
|
||||
latestHeartbeatSeries:
|
||||
value.latestHeartbeatSeries === undefined
|
||||
? null
|
||||
: (value.latestHeartbeatSeries as HealthHeartbeatSeriesSummary | null),
|
||||
contextConfidence: value.contextConfidence as number,
|
||||
fetchedAt: value.fetchedAt as string,
|
||||
};
|
||||
}
|
||||
|
||||
function isSelfReport(value: unknown): value is MeasurementSelfReport {
|
||||
if (!isRecord(value)) return false;
|
||||
const isOptionalBoolean = (item: unknown): item is boolean | null =>
|
||||
item === null || typeof item === 'boolean';
|
||||
return (
|
||||
[1, 2, 3, 4, 5].includes(Number(value.feeling)) &&
|
||||
(value.posture === null || value.posture === 'seated' || value.posture === 'lying') &&
|
||||
isOptionalBoolean(value.recentExercise) &&
|
||||
isOptionalBoolean(value.recentCaffeine) &&
|
||||
isOptionalBoolean(value.recentMeal) &&
|
||||
isOptionalBoolean(value.movementDuringMeasurement) &&
|
||||
isOptionalBoolean(value.phoneUnusuallyWarm) &&
|
||||
isValidDateString(value.recordedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function reportAllowsBaseline(report: MeasurementSelfReport | null): boolean {
|
||||
if (report === null) return true;
|
||||
return ![
|
||||
report.recentExercise,
|
||||
report.recentCaffeine,
|
||||
report.recentMeal,
|
||||
report.movementDuringMeasurement,
|
||||
report.phoneUnusuallyWarm,
|
||||
].some(value => value === true);
|
||||
}
|
||||
|
||||
function parseStoredMeasurement(value: unknown): StoredMeasurement | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const selfReport = value.selfReport === null
|
||||
? null
|
||||
: isSelfReport(value.selfReport)
|
||||
? value.selfReport
|
||||
: undefined;
|
||||
const healthKitContext =
|
||||
value.healthKitContext === null
|
||||
? null
|
||||
: parseHealthKitContext(value.healthKitContext);
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
typeof value.id !== 'string' ||
|
||||
!isValidDateString(value.measuredAt) ||
|
||||
typeof value.atRest !== 'boolean' ||
|
||||
!isProcessingResult(value.cameraResult) ||
|
||||
!isPotentialStressResult(value.potentialStress) ||
|
||||
healthKitContext === undefined ||
|
||||
selfReport === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Older v1 rows did not persist this derived field; infer it during migration.
|
||||
const baselineEligible =
|
||||
typeof value.baselineEligible === 'boolean'
|
||||
? value.baselineEligible
|
||||
: value.atRest && reportAllowsBaseline(selfReport);
|
||||
const rawAcquisition = isRecord(value.acquisition) ? value.acquisition : null;
|
||||
const cameraPosition =
|
||||
rawAcquisition?.cameraPosition === 'front' || rawAcquisition?.cameraPosition === 'back'
|
||||
? rawAcquisition.cameraPosition
|
||||
: 'unknown';
|
||||
const torchUsed =
|
||||
typeof rawAcquisition?.torchUsed === 'boolean' ? rawAcquisition.torchUsed : null;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
...(typeof value.algorithmVersion === 'string'
|
||||
? {algorithmVersion: value.algorithmVersion}
|
||||
: {}),
|
||||
id: value.id,
|
||||
acquisition: {
|
||||
cameraPosition,
|
||||
torchUsed,
|
||||
...(typeof rawAcquisition?.lens === 'string' || rawAcquisition?.lens === null
|
||||
? {lens: rawAcquisition.lens as string | null}
|
||||
: {}),
|
||||
...(typeof rawAcquisition?.deviceModel === 'string' ||
|
||||
rawAcquisition?.deviceModel === null
|
||||
? {deviceModel: rawAcquisition.deviceModel as string | null}
|
||||
: {}),
|
||||
},
|
||||
measuredAt: value.measuredAt,
|
||||
atRest: value.atRest,
|
||||
baselineEligible,
|
||||
cameraResult: value.cameraResult,
|
||||
potentialStress: value.potentialStress,
|
||||
healthKitContext,
|
||||
selfReport,
|
||||
};
|
||||
}
|
||||
|
||||
let mutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const DEFAULT_DATA_POLICY: PPGDataPolicy = {
|
||||
schemaVersion: 1,
|
||||
retentionPolicy: 'forever',
|
||||
};
|
||||
|
||||
const retentionDays: Record<Exclude<DataRetentionPolicy, 'forever'>, number> = {
|
||||
'30days': 30,
|
||||
'90days': 90,
|
||||
'1year': 365,
|
||||
};
|
||||
|
||||
function retainMeasurements(
|
||||
measurements: readonly StoredMeasurement[],
|
||||
policy: DataRetentionPolicy,
|
||||
now = Date.now(),
|
||||
): StoredMeasurement[] {
|
||||
if (policy === 'forever') return [...measurements];
|
||||
const cutoff = now - retentionDays[policy] * 86_400_000;
|
||||
return measurements.filter(measurement => Date.parse(measurement.measuredAt) >= cutoff);
|
||||
}
|
||||
|
||||
async function readDataPolicy(): Promise<PPGDataPolicy> {
|
||||
const stored = await readJSON<Partial<PPGDataPolicy>>(
|
||||
storageKeys.dataPolicy,
|
||||
DEFAULT_DATA_POLICY,
|
||||
);
|
||||
const retentionPolicy = stored.retentionPolicy;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
retentionPolicy:
|
||||
retentionPolicy === '30days' ||
|
||||
retentionPolicy === '90days' ||
|
||||
retentionPolicy === '1year' ||
|
||||
retentionPolicy === 'forever'
|
||||
? retentionPolicy
|
||||
: 'forever',
|
||||
};
|
||||
}
|
||||
|
||||
async function readAllFromStorage(): Promise<StoredMeasurement[]> {
|
||||
const raw = await readJSON<unknown>(storageKeys.measurements, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map(parseStoredMeasurement)
|
||||
.filter((measurement): measurement is StoredMeasurement => measurement !== null)
|
||||
.sort((left, right) => Date.parse(right.measuredAt) - Date.parse(left.measuredAt));
|
||||
}
|
||||
|
||||
function enqueueMutation(operation: () => Promise<void>): Promise<void> {
|
||||
const pending = mutationQueue.then(operation);
|
||||
mutationQueue = pending.catch(() => undefined);
|
||||
return pending;
|
||||
}
|
||||
|
||||
export const measurementRepository = {
|
||||
async getAll(): Promise<StoredMeasurement[]> {
|
||||
await mutationQueue;
|
||||
const [all, policy] = await Promise.all([readAllFromStorage(), readDataPolicy()]);
|
||||
const retained = retainMeasurements(all, policy.retentionPolicy);
|
||||
if (retained.length !== all.length) {
|
||||
await writeJSON(storageKeys.measurements, retained);
|
||||
}
|
||||
return retained;
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<StoredMeasurement | null> {
|
||||
return (await this.getAll()).find(measurement => measurement.id === id) ?? null;
|
||||
},
|
||||
|
||||
async add(measurement: StoredMeasurement): Promise<void> {
|
||||
return enqueueMutation(async () => {
|
||||
const current = await readAllFromStorage();
|
||||
const withoutDuplicate = current.filter(item => item.id !== measurement.id);
|
||||
const policy = await readDataPolicy();
|
||||
await writeJSON(
|
||||
storageKeys.measurements,
|
||||
retainMeasurements([measurement, ...withoutDuplicate], policy.retentionPolicy),
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async attachSelfReport(
|
||||
measurementId: string,
|
||||
selfReport: MeasurementSelfReport,
|
||||
): Promise<void> {
|
||||
return enqueueMutation(async () => {
|
||||
const current = await readAllFromStorage();
|
||||
await writeJSON(
|
||||
storageKeys.measurements,
|
||||
current.map(measurement =>
|
||||
measurement.id === measurementId
|
||||
? {
|
||||
...measurement,
|
||||
selfReport,
|
||||
baselineEligible:
|
||||
measurement.baselineEligible &&
|
||||
measurement.atRest &&
|
||||
reportAllowsBaseline(selfReport),
|
||||
}
|
||||
: measurement,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async deleteById(measurementId: string): Promise<void> {
|
||||
return enqueueMutation(async () => {
|
||||
const current = await readAllFromStorage();
|
||||
await writeJSON(
|
||||
storageKeys.measurements,
|
||||
current.filter(measurement => measurement.id !== measurementId),
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
return enqueueMutation(async () => {
|
||||
await writeJSON(storageKeys.measurements, []);
|
||||
});
|
||||
},
|
||||
|
||||
async getRetentionPolicy(): Promise<DataRetentionPolicy> {
|
||||
await mutationQueue;
|
||||
return (await readDataPolicy()).retentionPolicy;
|
||||
},
|
||||
|
||||
async setRetentionPolicy(policy: DataRetentionPolicy): Promise<void> {
|
||||
return enqueueMutation(async () => {
|
||||
await writeJSON<PPGDataPolicy>(storageKeys.dataPolicy, {
|
||||
schemaVersion: 1,
|
||||
retentionPolicy: policy,
|
||||
});
|
||||
const current = await readAllFromStorage();
|
||||
await writeJSON(storageKeys.measurements, retainMeasurements(current, policy));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function exportMeasurementsAsJSON(
|
||||
measurements: readonly StoredMeasurement[],
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
format: 'haptika-ppg-export',
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
measurements,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
const csvCell = (value: string | number | boolean | null | undefined): string => {
|
||||
const normalized = value === null || value === undefined ? '' : String(value);
|
||||
return `"${normalized.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
export function exportMeasurementsAsCSV(
|
||||
measurements: readonly StoredMeasurement[],
|
||||
): string {
|
||||
const rows = measurements.map(measurement => [
|
||||
measurement.id,
|
||||
measurement.measuredAt,
|
||||
measurement.acquisition.cameraPosition,
|
||||
measurement.cameraResult.bpm.bpm,
|
||||
measurement.cameraResult.prv.estimatedRmssdMs,
|
||||
measurement.cameraResult.prv.estimatedSdnnMs,
|
||||
measurement.cameraResult.quality.score,
|
||||
measurement.potentialStress.index,
|
||||
measurement.selfReport?.feeling,
|
||||
measurement.baselineEligible,
|
||||
]);
|
||||
return [
|
||||
['id', 'measured_at', 'camera', 'bpm', 'rmssd_ms', 'sdnn_ms', 'quality', 'index', 'feeling', 'baseline_eligible'],
|
||||
...rows,
|
||||
]
|
||||
.map(row => row.map(csvCell).join(','))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function toBaselineMeasurement(
|
||||
measurement: StoredMeasurement,
|
||||
): BaselineMeasurement {
|
||||
const measuredDate = new Date(measurement.measuredAt);
|
||||
return {
|
||||
id: measurement.id,
|
||||
source: 'cameraPPG',
|
||||
valid: measurement.cameraResult.valid && measurement.baselineEligible,
|
||||
measuredAt: measurement.measuredAt,
|
||||
bpm: measurement.cameraResult.bpm.bpm,
|
||||
estimatedRmssdMs: measurement.cameraResult.prv.estimatedRmssdMs,
|
||||
prvReliable: measurement.cameraResult.prv.reliable,
|
||||
validIntervalCount: measurement.cameraResult.prv.validIntervalCount,
|
||||
signalQuality: measurement.cameraResult.quality.score,
|
||||
atRest: measurement.atRest,
|
||||
measurementHourLocal: Number.isFinite(measuredDate.getTime())
|
||||
? measuredDate.getHours()
|
||||
: null,
|
||||
cameraPosition: measurement.acquisition.cameraPosition,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {readJSON, storageKeys, writeJSON} from './database';
|
||||
import type {PPGSettings} from './storageTypes';
|
||||
|
||||
const DEFAULT_SETTINGS: PPGSettings = {
|
||||
schemaVersion: 1,
|
||||
includeHealthKitContext: false,
|
||||
};
|
||||
|
||||
export const settingsRepository = {
|
||||
async get(): Promise<PPGSettings> {
|
||||
const stored = await readJSON<Partial<PPGSettings>>(
|
||||
storageKeys.settings,
|
||||
DEFAULT_SETTINGS,
|
||||
);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
includeHealthKitContext: stored.includeHealthKitContext === true,
|
||||
};
|
||||
},
|
||||
|
||||
async setIncludeHealthKitContext(value: boolean): Promise<void> {
|
||||
await writeJSON<PPGSettings>(storageKeys.settings, {
|
||||
schemaVersion: 1,
|
||||
includeHealthKitContext: value,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type {HealthKitContext} from '../health/healthTypes';
|
||||
import type {PPGProcessingResult} from '../measurement/measurementTypes';
|
||||
import type {PotentialStressResult} from '../stress/stressTypes';
|
||||
|
||||
export type FeelingScore = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
export interface MeasurementSelfReport {
|
||||
feeling: FeelingScore;
|
||||
posture: 'seated' | 'lying' | null;
|
||||
recentExercise: boolean | null;
|
||||
recentCaffeine: boolean | null;
|
||||
recentMeal: boolean | null;
|
||||
movementDuringMeasurement: boolean | null;
|
||||
phoneUnusuallyWarm: boolean | null;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface StoredMeasurement {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
measuredAt: string;
|
||||
atRest: boolean;
|
||||
/** Eligibility for future baselines; never changes the already displayed result. */
|
||||
baselineEligible: boolean;
|
||||
cameraResult: PPGProcessingResult;
|
||||
potentialStress: PotentialStressResult;
|
||||
healthKitContext: HealthKitContext | null;
|
||||
selfReport: MeasurementSelfReport | null;
|
||||
}
|
||||
|
||||
export interface PPGSettings {
|
||||
schemaVersion: 1;
|
||||
includeHealthKitContext: boolean;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
BaselineCalculationResult,
|
||||
BaselineConfiguration,
|
||||
BaselineContext,
|
||||
BaselineMeasurement,
|
||||
BaselineRejectionReason,
|
||||
BaselineStatus,
|
||||
PersonalBaseline,
|
||||
} from './baselineTypes';
|
||||
|
||||
const circularHourDistance = (left: number, right: number): number => {
|
||||
const distance = Math.abs(left - right);
|
||||
return Math.min(distance, 24 - distance);
|
||||
};
|
||||
|
||||
const optionalContextMatches = <T extends string | boolean>(
|
||||
measured: T | null | undefined,
|
||||
target: T | null | undefined,
|
||||
): boolean => target === null || target === undefined || measured === target;
|
||||
|
||||
export const isContextuallyComparable = (
|
||||
measurement: BaselineMeasurement,
|
||||
target: BaselineContext,
|
||||
maximumHourDistance = 4,
|
||||
): boolean => {
|
||||
if (
|
||||
target.measurementHourLocal !== null &&
|
||||
validMeasurementHour(target.measurementHourLocal) &&
|
||||
(!validMeasurementHour(measurement.measurementHourLocal) ||
|
||||
circularHourDistance(
|
||||
measurement.measurementHourLocal,
|
||||
target.measurementHourLocal,
|
||||
) > maximumHourDistance)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
optionalContextMatches(measurement.cameraPosition, target.cameraPosition) &&
|
||||
optionalContextMatches(measurement.posture, target.posture) &&
|
||||
optionalContextMatches(measurement.recentExercise, target.recentExercise) &&
|
||||
optionalContextMatches(measurement.recentCaffeine, target.recentCaffeine) &&
|
||||
optionalContextMatches(measurement.recentMeal, target.recentMeal)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses a contextual subset once it is sufficiently representative, otherwise
|
||||
* falls back to the complete resting baseline so early users are not blocked.
|
||||
*/
|
||||
export const calculateContextualBaseline = (
|
||||
measurements: readonly BaselineMeasurement[],
|
||||
target: BaselineContext,
|
||||
configuration: BaselineConfiguration = DEFAULT_BASELINE_CONFIGURATION,
|
||||
): BaselineCalculationResult => {
|
||||
const contextual = measurements.filter(measurement =>
|
||||
isContextuallyComparable(measurement, target),
|
||||
);
|
||||
const minimumContextualCount = Math.min(
|
||||
3,
|
||||
configuration.minMeasurementsForProvisional,
|
||||
);
|
||||
return calculateBaseline(
|
||||
contextual.length >= minimumContextualCount ? contextual : measurements,
|
||||
configuration,
|
||||
);
|
||||
};
|
||||
|
||||
export const DEFAULT_BASELINE_CONFIGURATION: Readonly<BaselineConfiguration> =
|
||||
Object.freeze({
|
||||
minimumSignalQuality: 0.65,
|
||||
minimumBpm: 30,
|
||||
maximumBpm: 240,
|
||||
minimumReliableIntervals: 20,
|
||||
minimumPrvMeasurements: 3,
|
||||
minMeasurementsForProvisional: 5,
|
||||
minMeasurementsForPersonalized: 10,
|
||||
});
|
||||
|
||||
const isFiniteNumber = (value: number | null): value is number =>
|
||||
value !== null && Number.isFinite(value);
|
||||
|
||||
export const median = (values: readonly number[]): number | null => {
|
||||
const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||||
if (finiteValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const middle = Math.floor(finiteValues.length / 2);
|
||||
if (finiteValues.length % 2 === 0) {
|
||||
const left = finiteValues[middle - 1];
|
||||
const right = finiteValues[middle];
|
||||
return left === undefined || right === undefined ? null : (left + right) / 2;
|
||||
}
|
||||
return finiteValues[middle] ?? null;
|
||||
};
|
||||
|
||||
export const medianAbsoluteDeviation = (
|
||||
values: readonly number[],
|
||||
center: number,
|
||||
): number | null => median(values.map(value => Math.abs(value - center)));
|
||||
|
||||
const rejectionReason = (
|
||||
measurement: BaselineMeasurement,
|
||||
configuration: BaselineConfiguration,
|
||||
): BaselineRejectionReason | null => {
|
||||
if (measurement.source !== 'cameraPPG') {
|
||||
return 'nonCameraSource';
|
||||
}
|
||||
if (!measurement.valid) {
|
||||
return 'invalidMeasurement';
|
||||
}
|
||||
if (!measurement.atRest) {
|
||||
return 'notAtRest';
|
||||
}
|
||||
if (!Number.isFinite(Date.parse(measurement.measuredAt))) {
|
||||
return 'invalidDate';
|
||||
}
|
||||
if (
|
||||
!isFiniteNumber(measurement.bpm) ||
|
||||
measurement.bpm < configuration.minimumBpm ||
|
||||
measurement.bpm > configuration.maximumBpm
|
||||
) {
|
||||
return 'invalidBpm';
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(measurement.signalQuality) ||
|
||||
measurement.signalQuality < configuration.minimumSignalQuality ||
|
||||
measurement.signalQuality > 1
|
||||
) {
|
||||
return 'insufficientSignalQuality';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validMeasurementHour = (hour: number | null): hour is number =>
|
||||
hour !== null && Number.isFinite(hour) && hour >= 0 && hour < 24;
|
||||
|
||||
const isReliablePrvMeasurement = (
|
||||
measurement: BaselineMeasurement,
|
||||
configuration: BaselineConfiguration,
|
||||
): boolean =>
|
||||
measurement.prvReliable &&
|
||||
measurement.validIntervalCount >= configuration.minimumReliableIntervals &&
|
||||
isFiniteNumber(measurement.estimatedRmssdMs) &&
|
||||
measurement.estimatedRmssdMs > 0;
|
||||
|
||||
export const calculateBaseline = (
|
||||
measurements: readonly BaselineMeasurement[],
|
||||
configuration: BaselineConfiguration = DEFAULT_BASELINE_CONFIGURATION,
|
||||
): BaselineCalculationResult => {
|
||||
const accepted: BaselineMeasurement[] = [];
|
||||
const rejected: BaselineCalculationResult['rejectedMeasurements'][number][] =
|
||||
[];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const measurement of measurements) {
|
||||
if (seenIds.has(measurement.id)) {
|
||||
rejected.push({
|
||||
id: measurement.id,
|
||||
reason: 'duplicateMeasurement',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
seenIds.add(measurement.id);
|
||||
|
||||
const reason = rejectionReason(measurement, configuration);
|
||||
if (reason !== null) {
|
||||
rejected.push({id: measurement.id, reason});
|
||||
continue;
|
||||
}
|
||||
accepted.push(measurement);
|
||||
}
|
||||
|
||||
if (accepted.length === 0) {
|
||||
return {
|
||||
baseline: null,
|
||||
acceptedMeasurementIds: [],
|
||||
rejectedMeasurements: rejected,
|
||||
};
|
||||
}
|
||||
|
||||
const bpmValues = accepted.map(measurement => measurement.bpm as number);
|
||||
const medianBpm = median(bpmValues) as number;
|
||||
const bpmMad = medianAbsoluteDeviation(bpmValues, medianBpm) as number;
|
||||
const medianSignalQuality = median(
|
||||
accepted.map(measurement => measurement.signalQuality),
|
||||
) as number;
|
||||
|
||||
const logRmssdValues = accepted
|
||||
.filter(measurement =>
|
||||
isReliablePrvMeasurement(measurement, configuration),
|
||||
)
|
||||
.map(measurement => Math.log(measurement.estimatedRmssdMs as number));
|
||||
const medianLogRmssd =
|
||||
logRmssdValues.length >= configuration.minimumPrvMeasurements
|
||||
? median(logRmssdValues)
|
||||
: null;
|
||||
const logRmssdMad =
|
||||
medianLogRmssd === null
|
||||
? null
|
||||
: medianAbsoluteDeviation(logRmssdValues, medianLogRmssd);
|
||||
|
||||
const measurementHours = accepted
|
||||
.map(measurement => measurement.measurementHourLocal)
|
||||
.filter(validMeasurementHour);
|
||||
const updatedAtMilliseconds = Math.max(
|
||||
...accepted.map(measurement => Date.parse(measurement.measuredAt)),
|
||||
);
|
||||
|
||||
return {
|
||||
baseline: {
|
||||
validMeasurementCount: accepted.length,
|
||||
medianBpm,
|
||||
bpmMad,
|
||||
medianLogRmssd,
|
||||
logRmssdMad,
|
||||
medianSignalQuality,
|
||||
measurementHourMedian: median(measurementHours),
|
||||
updatedAt: new Date(updatedAtMilliseconds).toISOString(),
|
||||
},
|
||||
acceptedMeasurementIds: accepted.map(measurement => measurement.id),
|
||||
rejectedMeasurements: rejected,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculatePersonalBaseline = (
|
||||
measurements: readonly BaselineMeasurement[],
|
||||
configuration: BaselineConfiguration = DEFAULT_BASELINE_CONFIGURATION,
|
||||
): PersonalBaseline | null =>
|
||||
calculateBaseline(measurements, configuration).baseline;
|
||||
|
||||
export const getBaselineStatus = (
|
||||
validMeasurementCount: number,
|
||||
configuration: Pick<
|
||||
BaselineConfiguration,
|
||||
'minMeasurementsForProvisional' | 'minMeasurementsForPersonalized'
|
||||
> = DEFAULT_BASELINE_CONFIGURATION,
|
||||
): BaselineStatus => {
|
||||
if (
|
||||
validMeasurementCount >= configuration.minMeasurementsForPersonalized
|
||||
) {
|
||||
return 'personalized';
|
||||
}
|
||||
if (validMeasurementCount >= configuration.minMeasurementsForProvisional) {
|
||||
return 'provisional';
|
||||
}
|
||||
return 'building';
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
export type BaselineMeasurementSource =
|
||||
| 'cameraPPG'
|
||||
| 'healthKit'
|
||||
| 'manualFixture';
|
||||
|
||||
export interface BaselineMeasurement {
|
||||
id: string;
|
||||
source: BaselineMeasurementSource;
|
||||
valid: boolean;
|
||||
measuredAt: string;
|
||||
bpm: number | null;
|
||||
estimatedRmssdMs: number | null;
|
||||
prvReliable: boolean;
|
||||
validIntervalCount: number;
|
||||
signalQuality: number;
|
||||
atRest: boolean;
|
||||
measurementHourLocal: number | null;
|
||||
}
|
||||
|
||||
export interface PersonalBaseline {
|
||||
validMeasurementCount: number;
|
||||
medianBpm: number;
|
||||
bpmMad: number;
|
||||
medianLogRmssd: number | null;
|
||||
logRmssdMad: number | null;
|
||||
medianSignalQuality: number;
|
||||
measurementHourMedian: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BaselineConfiguration {
|
||||
minimumSignalQuality: number;
|
||||
minimumBpm: number;
|
||||
maximumBpm: number;
|
||||
minimumReliableIntervals: number;
|
||||
minimumPrvMeasurements: number;
|
||||
minMeasurementsForProvisional: number;
|
||||
minMeasurementsForPersonalized: number;
|
||||
}
|
||||
|
||||
export type BaselineStatus =
|
||||
| 'building'
|
||||
| 'provisional'
|
||||
| 'personalized';
|
||||
|
||||
export type BaselineRejectionReason =
|
||||
| 'duplicateMeasurement'
|
||||
| 'nonCameraSource'
|
||||
| 'invalidMeasurement'
|
||||
| 'notAtRest'
|
||||
| 'invalidDate'
|
||||
| 'invalidBpm'
|
||||
| 'insufficientSignalQuality';
|
||||
|
||||
export interface RejectedBaselineMeasurement {
|
||||
id: string;
|
||||
reason: BaselineRejectionReason;
|
||||
}
|
||||
|
||||
export interface BaselineCalculationResult {
|
||||
baseline: PersonalBaseline | null;
|
||||
acceptedMeasurementIds: readonly string[];
|
||||
rejectedMeasurements: readonly RejectedBaselineMeasurement[];
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {StressIndexConfiguration} from './stressIndexConfiguration';
|
||||
import {DEFAULT_STRESS_INDEX_CONFIGURATION} from './stressIndexConfiguration';
|
||||
|
||||
export interface ConfidenceConfiguration {
|
||||
signalQualityWeight: number;
|
||||
baselineWeight: number;
|
||||
peakWeight: number;
|
||||
methodAgreementWeight: number;
|
||||
contextConsistencyWeight: number;
|
||||
baselineBuildingConfidenceCap: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIDENCE_CONFIGURATION: Readonly<ConfidenceConfiguration> =
|
||||
Object.freeze({
|
||||
signalQualityWeight: 0.4,
|
||||
baselineWeight: 0.2,
|
||||
peakWeight: 0.2,
|
||||
methodAgreementWeight: 0.15,
|
||||
contextConsistencyWeight: 0.05,
|
||||
baselineBuildingConfidenceCap: 0.49,
|
||||
});
|
||||
|
||||
export interface ConfidenceInput {
|
||||
signalQuality: number;
|
||||
usableDurationSeconds: number;
|
||||
targetDurationSeconds: number;
|
||||
baselineMeasurementCount: number;
|
||||
peakConfidence: number;
|
||||
methodAgreementScore: number;
|
||||
contextConsistency: number;
|
||||
prvReliable: boolean;
|
||||
}
|
||||
|
||||
export interface ConfidenceBreakdown {
|
||||
signalQuality: number;
|
||||
duration: number;
|
||||
baseline: number;
|
||||
peaks: number;
|
||||
methodAgreement: number;
|
||||
contextConsistency: number;
|
||||
}
|
||||
|
||||
export interface ConfidenceResult {
|
||||
score: number;
|
||||
breakdown: ConfidenceBreakdown;
|
||||
modifiers: readonly ('missingReliablePrv' | 'baselineCap')[];
|
||||
}
|
||||
|
||||
const clamp01 = (value: number): number =>
|
||||
Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0;
|
||||
|
||||
export const calculateConfidence = (
|
||||
input: ConfidenceInput,
|
||||
stressConfiguration: StressIndexConfiguration =
|
||||
DEFAULT_STRESS_INDEX_CONFIGURATION,
|
||||
confidenceConfiguration: ConfidenceConfiguration =
|
||||
DEFAULT_CONFIDENCE_CONFIGURATION,
|
||||
): ConfidenceResult => {
|
||||
const duration =
|
||||
Number.isFinite(input.targetDurationSeconds) &&
|
||||
input.targetDurationSeconds > 0
|
||||
? clamp01(input.usableDurationSeconds / input.targetDurationSeconds)
|
||||
: 0;
|
||||
const signalQuality = clamp01(input.signalQuality);
|
||||
const baseline = clamp01(
|
||||
input.baselineMeasurementCount /
|
||||
stressConfiguration.minMeasurementsForPersonalized,
|
||||
);
|
||||
const peaks = clamp01(input.peakConfidence);
|
||||
const methodAgreement = clamp01(input.methodAgreementScore);
|
||||
const contextConsistency = clamp01(input.contextConsistency);
|
||||
const effectiveSignalQuality = signalQuality * duration;
|
||||
|
||||
let score =
|
||||
confidenceConfiguration.signalQualityWeight * effectiveSignalQuality +
|
||||
confidenceConfiguration.baselineWeight * baseline +
|
||||
confidenceConfiguration.peakWeight * peaks +
|
||||
confidenceConfiguration.methodAgreementWeight * methodAgreement +
|
||||
confidenceConfiguration.contextConsistencyWeight * contextConsistency;
|
||||
const modifiers: ConfidenceResult['modifiers'][number][] = [];
|
||||
|
||||
if (!input.prvReliable) {
|
||||
score *= stressConfiguration.unreliablePrvConfidenceMultiplier;
|
||||
modifiers.push('missingReliablePrv');
|
||||
}
|
||||
|
||||
if (
|
||||
input.baselineMeasurementCount <
|
||||
stressConfiguration.minMeasurementsForProvisional
|
||||
) {
|
||||
score = Math.min(
|
||||
score,
|
||||
confidenceConfiguration.baselineBuildingConfidenceCap,
|
||||
);
|
||||
modifiers.push('baselineCap');
|
||||
} else if (
|
||||
input.baselineMeasurementCount <
|
||||
stressConfiguration.minMeasurementsForPersonalized
|
||||
) {
|
||||
score = Math.min(score, stressConfiguration.provisionalConfidenceCap);
|
||||
modifiers.push('baselineCap');
|
||||
}
|
||||
|
||||
return {
|
||||
score: clamp01(score),
|
||||
breakdown: {
|
||||
signalQuality,
|
||||
duration,
|
||||
baseline,
|
||||
peaks,
|
||||
methodAgreement,
|
||||
contextConsistency,
|
||||
},
|
||||
modifiers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import {calculateConfidence} from './confidenceCalculator';
|
||||
import type {
|
||||
PotentialStressInput,
|
||||
PotentialStressResult,
|
||||
PotentialStressStatus,
|
||||
StressLimitation,
|
||||
} from './stressTypes';
|
||||
import type {StressIndexConfiguration} from './stressIndexConfiguration';
|
||||
import {DEFAULT_STRESS_INDEX_CONFIGURATION} from './stressIndexConfiguration';
|
||||
|
||||
const ROBUST_MAD_SCALE = 1.4826;
|
||||
const TARGET_USABLE_DURATION_SECONDS = 55;
|
||||
|
||||
const clamp = (value: number, minimum: number, maximum: number): number =>
|
||||
Math.min(Math.max(value, minimum), maximum);
|
||||
|
||||
const clamp01 = (value: number): number =>
|
||||
Number.isFinite(value) ? clamp(value, 0, 1) : 0;
|
||||
|
||||
const sigmoid = (value: number): number => 1 / (1 + Math.exp(-value));
|
||||
|
||||
export const robustZScore = (
|
||||
value: number,
|
||||
baselineMedian: number,
|
||||
baselineMad: number,
|
||||
configuration: Pick<StressIndexConfiguration, 'epsilon' | 'zScoreLimit'>,
|
||||
): number => {
|
||||
if (
|
||||
!Number.isFinite(value) ||
|
||||
!Number.isFinite(baselineMedian) ||
|
||||
!Number.isFinite(baselineMad) ||
|
||||
baselineMad < 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const denominator = ROBUST_MAD_SCALE * baselineMad + configuration.epsilon;
|
||||
return clamp(
|
||||
(value - baselineMedian) / denominator,
|
||||
-configuration.zScoreLimit,
|
||||
configuration.zScoreLimit,
|
||||
);
|
||||
};
|
||||
|
||||
const statusForBaselineCount = (
|
||||
count: number,
|
||||
configuration: StressIndexConfiguration,
|
||||
): PotentialStressStatus => {
|
||||
if (count < configuration.minMeasurementsForProvisional) {
|
||||
return 'baselineBuilding';
|
||||
}
|
||||
if (count < configuration.minMeasurementsForPersonalized) {
|
||||
return 'provisional';
|
||||
}
|
||||
return 'personalized';
|
||||
};
|
||||
|
||||
const buildEmptyResult = (
|
||||
input: PotentialStressInput,
|
||||
status: Extract<PotentialStressStatus, 'unavailable' | 'baselineBuilding'>,
|
||||
limitations: readonly StressLimitation[],
|
||||
): PotentialStressResult => ({
|
||||
index: null,
|
||||
confidence: 0,
|
||||
status,
|
||||
bpmContribution: null,
|
||||
prvContribution: null,
|
||||
signalQuality: clamp01(input.signalQuality),
|
||||
baselineMeasurementCount: input.baseline?.validMeasurementCount ?? 0,
|
||||
healthKitUsedForScore: false,
|
||||
limitations,
|
||||
});
|
||||
|
||||
export const calculatePotentialStress = (
|
||||
input: PotentialStressInput,
|
||||
configuration: StressIndexConfiguration =
|
||||
DEFAULT_STRESS_INDEX_CONFIGURATION,
|
||||
): PotentialStressResult => {
|
||||
const signalQuality = clamp01(input.signalQuality);
|
||||
if (
|
||||
input.bpm === null ||
|
||||
!Number.isFinite(input.bpm) ||
|
||||
input.bpm <= 0
|
||||
) {
|
||||
return buildEmptyResult(input, 'unavailable', ['invalidBpm']);
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(input.signalQuality) ||
|
||||
signalQuality < configuration.minSignalQuality
|
||||
) {
|
||||
return buildEmptyResult(input, 'unavailable', [
|
||||
'signalQualityTooLow',
|
||||
]);
|
||||
}
|
||||
if (input.atRest === false) {
|
||||
return buildEmptyResult(input, 'unavailable', ['measurementNotAtRest']);
|
||||
}
|
||||
|
||||
const baseline = input.baseline;
|
||||
if (
|
||||
baseline === null ||
|
||||
baseline.validMeasurementCount <
|
||||
configuration.minMeasurementsForProvisional
|
||||
) {
|
||||
return buildEmptyResult(input, 'baselineBuilding', ['baselineBuilding']);
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(baseline.medianBpm) ||
|
||||
!Number.isFinite(baseline.bpmMad) ||
|
||||
baseline.bpmMad < 0
|
||||
) {
|
||||
return buildEmptyResult(input, 'unavailable', ['invalidBpm']);
|
||||
}
|
||||
|
||||
const limitations = new Set<StressLimitation>();
|
||||
const status = statusForBaselineCount(
|
||||
baseline.validMeasurementCount,
|
||||
configuration,
|
||||
);
|
||||
if (status === 'provisional') {
|
||||
limitations.add('provisionalBaseline');
|
||||
}
|
||||
if (baseline.bpmMad <= configuration.epsilon) {
|
||||
limitations.add('baselineBpmDispersionLow');
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(input.usableDurationSeconds) ||
|
||||
input.usableDurationSeconds < TARGET_USABLE_DURATION_SECONDS * 0.9
|
||||
) {
|
||||
limitations.add('usableDurationTooShort');
|
||||
}
|
||||
|
||||
const zBpm = robustZScore(
|
||||
input.bpm,
|
||||
baseline.medianBpm,
|
||||
baseline.bpmMad,
|
||||
configuration,
|
||||
);
|
||||
const bpmActivation = sigmoid(configuration.bpmSensitivity * zBpm);
|
||||
|
||||
const currentPrvAvailable =
|
||||
input.estimatedRmssdMs !== null &&
|
||||
Number.isFinite(input.estimatedRmssdMs) &&
|
||||
input.estimatedRmssdMs > 0;
|
||||
const enoughIntervals =
|
||||
input.validIntervalCount >= configuration.minReliableIntervals;
|
||||
const baselinePrvAvailable =
|
||||
baseline.medianLogRmssd !== null &&
|
||||
Number.isFinite(baseline.medianLogRmssd) &&
|
||||
baseline.logRmssdMad !== null &&
|
||||
Number.isFinite(baseline.logRmssdMad) &&
|
||||
baseline.logRmssdMad >= 0;
|
||||
const usePrv =
|
||||
input.prvReliable &&
|
||||
currentPrvAvailable &&
|
||||
enoughIntervals &&
|
||||
baselinePrvAvailable;
|
||||
|
||||
if (!currentPrvAvailable) {
|
||||
limitations.add('prvUnavailable');
|
||||
} else if (!input.prvReliable) {
|
||||
limitations.add('prvUnreliable');
|
||||
} else if (!enoughIntervals) {
|
||||
limitations.add('insufficientReliableIntervals');
|
||||
}
|
||||
if (!baselinePrvAvailable) {
|
||||
limitations.add('baselinePrvUnavailable');
|
||||
}
|
||||
|
||||
let bpmContribution: number;
|
||||
let prvContribution: number | null = null;
|
||||
let rawActivation: number;
|
||||
if (usePrv) {
|
||||
const zPrv = robustZScore(
|
||||
Math.log(input.estimatedRmssdMs as number),
|
||||
baseline.medianLogRmssd as number,
|
||||
baseline.logRmssdMad as number,
|
||||
configuration,
|
||||
);
|
||||
const prvActivation = sigmoid(-configuration.prvSensitivity * zPrv);
|
||||
bpmContribution = configuration.bpmWeight * bpmActivation;
|
||||
prvContribution = configuration.prvWeight * prvActivation;
|
||||
rawActivation = bpmContribution + prvContribution;
|
||||
} else {
|
||||
bpmContribution = bpmActivation;
|
||||
rawActivation = bpmActivation;
|
||||
}
|
||||
|
||||
const confidence = calculateConfidence(
|
||||
{
|
||||
signalQuality,
|
||||
usableDurationSeconds: input.usableDurationSeconds,
|
||||
targetDurationSeconds: TARGET_USABLE_DURATION_SECONDS,
|
||||
baselineMeasurementCount: baseline.validMeasurementCount,
|
||||
peakConfidence: input.peakConfidence,
|
||||
methodAgreementScore: input.methodAgreementScore,
|
||||
contextConsistency: input.contextConsistency ?? 0.5,
|
||||
prvReliable: usePrv,
|
||||
},
|
||||
configuration,
|
||||
).score;
|
||||
|
||||
return {
|
||||
index: Math.round(clamp01(rawActivation) * 100),
|
||||
confidence,
|
||||
status,
|
||||
bpmContribution: clamp01(bpmContribution),
|
||||
prvContribution:
|
||||
prvContribution === null ? null : clamp01(prvContribution),
|
||||
signalQuality,
|
||||
baselineMeasurementCount: baseline.validMeasurementCount,
|
||||
healthKitUsedForScore: false,
|
||||
limitations: [...limitations],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import {PPG_PUBLICATION_THRESHOLDS} from '../measurement/measurementConfiguration';
|
||||
|
||||
export interface StressIndexConfiguration {
|
||||
bpmWeight: number;
|
||||
prvWeight: number;
|
||||
healthKitWeight: number;
|
||||
bpmSensitivity: number;
|
||||
prvSensitivity: number;
|
||||
minMeasurementsForProvisional: number;
|
||||
minMeasurementsForPersonalized: number;
|
||||
minSignalQuality: number;
|
||||
minReliableIntervals: number;
|
||||
epsilon: number;
|
||||
zScoreLimit: number;
|
||||
provisionalConfidenceCap: number;
|
||||
unreliablePrvConfidenceMultiplier: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_STRESS_INDEX_CONFIGURATION: Readonly<StressIndexConfiguration> =
|
||||
Object.freeze({
|
||||
bpmWeight: 0.6,
|
||||
prvWeight: 0.4,
|
||||
healthKitWeight: 0,
|
||||
bpmSensitivity: 1.2,
|
||||
prvSensitivity: 1.2,
|
||||
minMeasurementsForProvisional: 5,
|
||||
minMeasurementsForPersonalized: 10,
|
||||
minSignalQuality: PPG_PUBLICATION_THRESHOLDS.potentialIndexQuality,
|
||||
minReliableIntervals:
|
||||
PPG_PUBLICATION_THRESHOLDS.potentialIndexMinimumIntervals,
|
||||
epsilon: 1e-6,
|
||||
zScoreLimit: 3,
|
||||
provisionalConfidenceCap: 0.69,
|
||||
unreliablePrvConfidenceMultiplier: 0.6,
|
||||
});
|
||||
|
||||
const assertFinite = (name: string, value: number): void => {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new RangeError(`${name} must be finite.`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertRatio = (name: string, value: number): void => {
|
||||
assertFinite(name, value);
|
||||
if (value < 0 || value > 1) {
|
||||
throw new RangeError(`${name} must be between 0 and 1.`);
|
||||
}
|
||||
};
|
||||
|
||||
export const validateStressIndexConfiguration = (
|
||||
configuration: StressIndexConfiguration,
|
||||
): StressIndexConfiguration => {
|
||||
assertRatio('bpmWeight', configuration.bpmWeight);
|
||||
assertRatio('prvWeight', configuration.prvWeight);
|
||||
assertRatio('healthKitWeight', configuration.healthKitWeight);
|
||||
if (configuration.healthKitWeight !== 0) {
|
||||
throw new RangeError(
|
||||
'healthKitWeight must remain 0 until explicit scientific and product validation.',
|
||||
);
|
||||
}
|
||||
if (Math.abs(configuration.bpmWeight + configuration.prvWeight - 1) > 1e-9) {
|
||||
throw new RangeError('bpmWeight and prvWeight must sum to 1.');
|
||||
}
|
||||
|
||||
assertFinite('bpmSensitivity', configuration.bpmSensitivity);
|
||||
assertFinite('prvSensitivity', configuration.prvSensitivity);
|
||||
if (configuration.bpmSensitivity <= 0 || configuration.prvSensitivity <= 0) {
|
||||
throw new RangeError('Stress sensitivities must be positive.');
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isInteger(configuration.minMeasurementsForProvisional) ||
|
||||
!Number.isInteger(configuration.minMeasurementsForPersonalized) ||
|
||||
configuration.minMeasurementsForProvisional < 1 ||
|
||||
configuration.minMeasurementsForPersonalized <
|
||||
configuration.minMeasurementsForProvisional
|
||||
) {
|
||||
throw new RangeError('Baseline measurement thresholds are invalid.');
|
||||
}
|
||||
|
||||
assertRatio('minSignalQuality', configuration.minSignalQuality);
|
||||
if (
|
||||
!Number.isInteger(configuration.minReliableIntervals) ||
|
||||
configuration.minReliableIntervals < 3
|
||||
) {
|
||||
throw new RangeError('minReliableIntervals must be at least 3.');
|
||||
}
|
||||
|
||||
assertFinite('epsilon', configuration.epsilon);
|
||||
assertFinite('zScoreLimit', configuration.zScoreLimit);
|
||||
if (configuration.epsilon <= 0 || configuration.zScoreLimit <= 0) {
|
||||
throw new RangeError('epsilon and zScoreLimit must be positive.');
|
||||
}
|
||||
|
||||
assertRatio(
|
||||
'provisionalConfidenceCap',
|
||||
configuration.provisionalConfidenceCap,
|
||||
);
|
||||
assertRatio(
|
||||
'unreliablePrvConfidenceMultiplier',
|
||||
configuration.unreliablePrvConfidenceMultiplier,
|
||||
);
|
||||
return configuration;
|
||||
};
|
||||
|
||||
export const createStressIndexConfiguration = (
|
||||
overrides: Partial<StressIndexConfiguration> = {},
|
||||
): Readonly<StressIndexConfiguration> => {
|
||||
const configuration: StressIndexConfiguration = {
|
||||
...DEFAULT_STRESS_INDEX_CONFIGURATION,
|
||||
...overrides,
|
||||
};
|
||||
validateStressIndexConfiguration(configuration);
|
||||
return Object.freeze(configuration);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
PotentialStressInterpretation,
|
||||
PotentialStressInterpretationLevel,
|
||||
} from './stressTypes';
|
||||
|
||||
export const POTENTIAL_STRESS_DISCLAIMER =
|
||||
'Cette estimation dépend de la qualité du signal et de vos données habituelles. Elle ne constitue pas une mesure médicale du stress.';
|
||||
|
||||
const unavailable = (): PotentialStressInterpretation => ({
|
||||
level: 'unavailable',
|
||||
message: 'Estimation indisponible',
|
||||
disclaimer: POTENTIAL_STRESS_DISCLAIMER,
|
||||
});
|
||||
|
||||
export const interpretPotentialStressIndex = (
|
||||
index: number | null,
|
||||
): PotentialStressInterpretation => {
|
||||
if (index === null || !Number.isFinite(index) || index < 0 || index > 100) {
|
||||
return unavailable();
|
||||
}
|
||||
|
||||
let level: PotentialStressInterpretationLevel;
|
||||
let message: string;
|
||||
if (index <= 30) {
|
||||
level = 'low';
|
||||
message =
|
||||
'Peu de signes d’activation par rapport à votre niveau habituel';
|
||||
} else if (index <= 60) {
|
||||
level = 'moderate';
|
||||
message = 'Activation potentielle modérée';
|
||||
} else if (index <= 80) {
|
||||
level = 'high';
|
||||
message = 'Activation potentielle élevée';
|
||||
} else {
|
||||
level = 'veryHigh';
|
||||
message = 'Activation potentielle très élevée';
|
||||
}
|
||||
|
||||
return {level, message, disclaimer: POTENTIAL_STRESS_DISCLAIMER};
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {PersonalBaseline} from './baselineTypes';
|
||||
|
||||
export type PotentialStressStatus =
|
||||
| 'unavailable'
|
||||
| 'baselineBuilding'
|
||||
| 'provisional'
|
||||
| 'personalized';
|
||||
|
||||
export type StressLimitation =
|
||||
| 'invalidBpm'
|
||||
| 'measurementNotAtRest'
|
||||
| 'signalQualityTooLow'
|
||||
| 'baselineBuilding'
|
||||
| 'provisionalBaseline'
|
||||
| 'baselineBpmDispersionLow'
|
||||
| 'prvUnavailable'
|
||||
| 'prvUnreliable'
|
||||
| 'insufficientReliableIntervals'
|
||||
| 'baselinePrvUnavailable'
|
||||
| 'usableDurationTooShort';
|
||||
|
||||
export interface PotentialStressInput {
|
||||
atRest?: boolean;
|
||||
bpm: number | null;
|
||||
estimatedRmssdMs: number | null;
|
||||
prvReliable: boolean;
|
||||
validIntervalCount: number;
|
||||
signalQuality: number;
|
||||
usableDurationSeconds: number;
|
||||
peakConfidence: number;
|
||||
methodAgreementScore: number;
|
||||
contextConsistency?: number;
|
||||
baseline: PersonalBaseline | null;
|
||||
}
|
||||
|
||||
export interface PotentialStressResult {
|
||||
index: number | null;
|
||||
confidence: number;
|
||||
status: PotentialStressStatus;
|
||||
/** Weighted normalized contribution in [0, 1]. */
|
||||
bpmContribution: number | null;
|
||||
/** Weighted normalized contribution in [0, 1], or null when PRV is omitted. */
|
||||
prvContribution: number | null;
|
||||
signalQuality: number;
|
||||
baselineMeasurementCount: number;
|
||||
healthKitUsedForScore: false;
|
||||
limitations: readonly StressLimitation[];
|
||||
}
|
||||
|
||||
export type PotentialStressInterpretationLevel =
|
||||
| 'unavailable'
|
||||
| 'low'
|
||||
| 'moderate'
|
||||
| 'high'
|
||||
| 'veryHigh';
|
||||
|
||||
export interface PotentialStressInterpretation {
|
||||
level: PotentialStressInterpretationLevel;
|
||||
message: string;
|
||||
disclaimer: string;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import React, {useState} from 'react';
|
||||
import ReactTestRenderer from 'react-test-renderer';
|
||||
import {haptikaPPG} from 'react-native-haptika-ppg';
|
||||
|
||||
import {PPGCameraView} from '../camera/PPGCameraView';
|
||||
|
||||
jest.mock('react-native-worklets', () => ({
|
||||
scheduleOnRN: (
|
||||
callback: (...args: readonly unknown[]) => void,
|
||||
...args: readonly unknown[]
|
||||
): void => callback(...args),
|
||||
}));
|
||||
|
||||
jest.mock('react-native-vision-camera', () => {
|
||||
const ReactModule = require('react') as typeof React;
|
||||
let frameOutputOptions: {
|
||||
onFrame: (frame: {dispose: () => void}) => void;
|
||||
};
|
||||
let cameraProps:
|
||||
| {
|
||||
enableLowLightBoost?: boolean;
|
||||
enableDistortionCorrection?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const controller = {
|
||||
device: {
|
||||
hasTorch: true,
|
||||
supportsTorchStrength: true,
|
||||
minTorchStrength: 0.2,
|
||||
maxTorchStrength: 1,
|
||||
supportsExposureBias: true,
|
||||
minExposureBias: -3,
|
||||
maxExposureBias: 3,
|
||||
supportsExposureLocking: true,
|
||||
supportsWhiteBalanceLocking: true,
|
||||
supportsFocusLocking: true,
|
||||
position: 'back',
|
||||
type: 'wide-angle',
|
||||
isVirtualDevice: false,
|
||||
},
|
||||
torchMode: 'off',
|
||||
torchStrength: 1,
|
||||
exposureBias: 0,
|
||||
enableTorchWithStrength: jest.fn(async () => undefined),
|
||||
setTorchMode: jest.fn(async () => undefined),
|
||||
setExposureBias: jest.fn(async () => undefined),
|
||||
lockCurrentExposure: jest.fn(async () => undefined),
|
||||
lockCurrentWhiteBalance: jest.fn(async () => undefined),
|
||||
lockCurrentFocus: jest.fn(async () => undefined),
|
||||
};
|
||||
let hookDevices: Array<typeof controller.device> = [controller.device];
|
||||
|
||||
return {
|
||||
Camera: ReactModule.forwardRef(
|
||||
(
|
||||
props: {
|
||||
device: unknown;
|
||||
onStarted: () => void;
|
||||
enableLowLightBoost?: boolean;
|
||||
enableDistortionCorrection?: boolean;
|
||||
},
|
||||
ref: React.ForwardedRef<{controller: typeof controller}>,
|
||||
) => {
|
||||
cameraProps = props;
|
||||
ReactModule.useImperativeHandle(ref, () => ({controller}));
|
||||
ReactModule.useEffect(() => props.onStarted(), [props]);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
useCameraDevices: () => hookDevices,
|
||||
useFrameOutput: (options: typeof frameOutputOptions) => {
|
||||
frameOutputOptions = options;
|
||||
return {};
|
||||
},
|
||||
__controller: controller,
|
||||
__getCameraProps: () => cameraProps,
|
||||
__getFrameOutputOptions: () => frameOutputOptions,
|
||||
__setHookDevices: (devices: Array<typeof controller.device>) => {
|
||||
hookDevices = devices;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-haptika-ppg', () => ({
|
||||
haptikaPPG: {
|
||||
processFrame: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
interface VisionCameraMock {
|
||||
__controller: {
|
||||
device: unknown;
|
||||
enableTorchWithStrength: jest.Mock;
|
||||
setTorchMode: jest.Mock;
|
||||
setExposureBias: jest.Mock;
|
||||
lockCurrentExposure: jest.Mock;
|
||||
lockCurrentWhiteBalance: jest.Mock;
|
||||
lockCurrentFocus: jest.Mock;
|
||||
};
|
||||
__getCameraProps: () =>
|
||||
| {
|
||||
device?: unknown;
|
||||
enableLowLightBoost?: boolean;
|
||||
enableDistortionCorrection?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
__getFrameOutputOptions: () => {
|
||||
onFrame: (frame: {dispose: () => void}) => void;
|
||||
};
|
||||
__setHookDevices: (devices: unknown[]) => void;
|
||||
}
|
||||
|
||||
const visionCamera = jest.requireMock(
|
||||
'react-native-vision-camera',
|
||||
) as VisionCameraMock;
|
||||
const nativePPG = haptikaPPG as jest.Mocked<typeof haptikaPPG>;
|
||||
|
||||
describe('PPGCameraView', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
visionCamera.__setHookDevices([visionCamera.__controller.device]);
|
||||
});
|
||||
|
||||
test('keeps the standard torch enabled after camera readiness', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [torchEnabled, setTorchEnabled] = useState(false);
|
||||
return (
|
||||
<PPGCameraView
|
||||
isActive
|
||||
torchEnabled={torchEnabled}
|
||||
lockOptics={false}
|
||||
onCameraStarted={() => setTorchEnabled(true)}
|
||||
onFrameUpdate={jest.fn()}
|
||||
onInterrupted={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer;
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(<Harness />);
|
||||
});
|
||||
|
||||
expect(
|
||||
visionCamera.__controller.enableTorchWithStrength,
|
||||
).toHaveBeenCalledWith(0.2);
|
||||
expect(visionCamera.__controller.setExposureBias).toHaveBeenCalledWith(-1);
|
||||
|
||||
ReactTestRenderer.act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('prefers the physical 0.5x camera when it has a torch', async () => {
|
||||
const wideDevice = visionCamera.__controller.device as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const ultraWideDevice = {
|
||||
...wideDevice,
|
||||
type: 'ultra-wide-angle',
|
||||
id: 'physical-ultra-wide',
|
||||
};
|
||||
visionCamera.__setHookDevices([
|
||||
visionCamera.__controller.device,
|
||||
ultraWideDevice,
|
||||
]);
|
||||
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer;
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(
|
||||
<PPGCameraView
|
||||
isActive
|
||||
torchEnabled={false}
|
||||
lockOptics={false}
|
||||
onCameraStarted={jest.fn()}
|
||||
onFrameUpdate={jest.fn()}
|
||||
onInterrupted={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
(visionCamera.__getCameraProps()?.device as {type: string}).type,
|
||||
).toBe('ultra-wide-angle');
|
||||
ReactTestRenderer.act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('selects the front camera without requiring a torch', async () => {
|
||||
const frontDevice = {
|
||||
...(visionCamera.__controller.device as Record<string, unknown>),
|
||||
id: 'front-wide',
|
||||
position: 'front',
|
||||
hasTorch: false,
|
||||
};
|
||||
visionCamera.__setHookDevices([
|
||||
visionCamera.__controller.device,
|
||||
frontDevice,
|
||||
]);
|
||||
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer;
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(
|
||||
<PPGCameraView
|
||||
isActive
|
||||
cameraPosition="front"
|
||||
torchEnabled
|
||||
lockOptics={false}
|
||||
onCameraStarted={jest.fn()}
|
||||
onFrameUpdate={jest.fn()}
|
||||
onInterrupted={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
(visionCamera.__getCameraProps()?.device as {position: string}).position,
|
||||
).toBe('front');
|
||||
expect(
|
||||
visionCamera.__controller.enableTorchWithStrength,
|
||||
).not.toHaveBeenCalled();
|
||||
ReactTestRenderer.act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('does not configure unsupported optional camera features', async () => {
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer;
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(
|
||||
<PPGCameraView
|
||||
isActive
|
||||
torchEnabled={false}
|
||||
lockOptics={false}
|
||||
onCameraStarted={jest.fn()}
|
||||
onFrameUpdate={jest.fn()}
|
||||
onInterrupted={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(visionCamera.__getCameraProps()).not.toHaveProperty(
|
||||
'enableLowLightBoost',
|
||||
);
|
||||
expect(visionCamera.__getCameraProps()).not.toHaveProperty(
|
||||
'enableDistortionCorrection',
|
||||
);
|
||||
|
||||
ReactTestRenderer.act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('skips isolated frame errors and reports a sustained processor failure', async () => {
|
||||
const onError = jest.fn();
|
||||
const dispose = jest.fn();
|
||||
nativePPG.processFrame.mockImplementation(() => {
|
||||
throw new Error('Native frame failure');
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer;
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(
|
||||
<PPGCameraView
|
||||
isActive
|
||||
torchEnabled={false}
|
||||
lockOptics={false}
|
||||
onCameraStarted={jest.fn()}
|
||||
onFrameUpdate={jest.fn()}
|
||||
onInterrupted={jest.fn()}
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
visionCamera.__getFrameOutputOptions().onFrame({dispose});
|
||||
}
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
visionCamera.__getFrameOutputOptions().onFrame({dispose});
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(10);
|
||||
expect(onError).toHaveBeenCalledWith({
|
||||
code: 'cameraRuntime',
|
||||
message: 'Native frame failure',
|
||||
});
|
||||
expect(visionCamera.__controller.setTorchMode).toHaveBeenCalledWith('off');
|
||||
|
||||
ReactTestRenderer.act(() => renderer.unmount());
|
||||
});
|
||||
|
||||
test('reports unavailable when the physical 1x camera is missing', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onError = jest.fn();
|
||||
visionCamera.__setHookDevices([]);
|
||||
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer;
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(
|
||||
<PPGCameraView
|
||||
isActive
|
||||
torchEnabled={false}
|
||||
lockOptics={false}
|
||||
onCameraStarted={jest.fn()}
|
||||
onFrameUpdate={jest.fn()}
|
||||
onInterrupted={jest.fn()}
|
||||
onError={onError}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await ReactTestRenderer.act(async () => {
|
||||
jest.advanceTimersByTime(2_000);
|
||||
});
|
||||
expect(onError).toHaveBeenCalledWith({
|
||||
code: 'cameraUnavailable',
|
||||
message: 'Aucune caméra arrière avec flash n’est disponible.',
|
||||
});
|
||||
|
||||
ReactTestRenderer.act(() => renderer.unmount());
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('locks optical controls after contact stabilizes', async () => {
|
||||
jest.useFakeTimers();
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer;
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(
|
||||
<PPGCameraView
|
||||
isActive
|
||||
torchEnabled={false}
|
||||
lockOptics
|
||||
onCameraStarted={jest.fn()}
|
||||
onFrameUpdate={jest.fn()}
|
||||
onInterrupted={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await ReactTestRenderer.act(async () => {
|
||||
jest.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
expect(visionCamera.__controller.lockCurrentExposure).toHaveBeenCalled();
|
||||
expect(
|
||||
visionCamera.__controller.lockCurrentWhiteBalance,
|
||||
).toHaveBeenCalled();
|
||||
expect(visionCamera.__controller.lockCurrentFocus).toHaveBeenCalled();
|
||||
|
||||
ReactTestRenderer.act(() => renderer.unmount());
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const mockStore = new Map<string, string>();
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null),
|
||||
setItem: jest.fn(async (key: string, value: string) => {
|
||||
mockStore.set(key, value);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import {acquisitionPreferencesRepository} from '../storage/acquisitionPreferencesRepository';
|
||||
|
||||
describe('acquisition preferences', () => {
|
||||
beforeEach(() => mockStore.clear());
|
||||
|
||||
test('uses safe defaults and persists camera/contact choices', async () => {
|
||||
await expect(acquisitionPreferencesRepository.get()).resolves.toEqual({
|
||||
cameraPosition: 'front',
|
||||
sensitiveContact: true,
|
||||
});
|
||||
await acquisitionPreferencesRepository.set({
|
||||
cameraPosition: 'back',
|
||||
sensitiveContact: false,
|
||||
});
|
||||
await expect(acquisitionPreferencesRepository.get()).resolves.toEqual({
|
||||
cameraPosition: 'back',
|
||||
sensitiveContact: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('repairs malformed persisted values', async () => {
|
||||
mockStore.set(
|
||||
'@haptika/ppg/v1/acquisition-preferences',
|
||||
JSON.stringify({cameraPosition: 'side', sensitiveContact: 'yes'}),
|
||||
);
|
||||
await expect(acquisitionPreferencesRepository.get()).resolves.toEqual({
|
||||
cameraPosition: 'front',
|
||||
sensitiveContact: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
calculateBaseline,
|
||||
calculateContextualBaseline,
|
||||
calculatePersonalBaseline,
|
||||
isContextuallyComparable,
|
||||
} from '../stress/baselineCalculator';
|
||||
import {calculatePotentialStress} from '../stress/potentialStressCalculator';
|
||||
import {createStressIndexConfiguration} from '../stress/stressIndexConfiguration';
|
||||
import {interpretPotentialStressIndex} from '../stress/stressInterpretation';
|
||||
import {
|
||||
makeBaselineMeasurement,
|
||||
makeBaselineMeasurements,
|
||||
} from './syntheticMeasurements';
|
||||
|
||||
describe('personal baseline', () => {
|
||||
test('compares local time circularly and respects recorded context', () => {
|
||||
const measurement = {
|
||||
...makeBaselineMeasurements(1)[0]!,
|
||||
measurementHourLocal: 23,
|
||||
posture: 'seated' as const,
|
||||
recentCaffeine: false,
|
||||
recentMeal: false,
|
||||
recentExercise: false,
|
||||
};
|
||||
expect(
|
||||
isContextuallyComparable(measurement, {
|
||||
measurementHourLocal: 1,
|
||||
posture: 'seated',
|
||||
recentCaffeine: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isContextuallyComparable(measurement, {measurementHourLocal: 12}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isContextuallyComparable(measurement, {
|
||||
measurementHourLocal: 1,
|
||||
posture: 'lying',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('uses a sufficiently populated contextual subset', () => {
|
||||
const measurements = makeBaselineMeasurements(6).map((measurement, index) => ({
|
||||
...measurement,
|
||||
measurementHourLocal: index < 3 ? 8 : 20,
|
||||
bpm: index < 3 ? 60 : 90,
|
||||
}));
|
||||
const result = calculateContextualBaseline(measurements, {
|
||||
measurementHourLocal: 8,
|
||||
});
|
||||
expect(result.baseline?.validMeasurementCount).toBe(3);
|
||||
expect(result.baseline?.medianBpm).toBe(60);
|
||||
});
|
||||
|
||||
test('uses camera-only valid resting measurements and robust statistics', () => {
|
||||
const measurements = [
|
||||
...makeBaselineMeasurements(10),
|
||||
makeBaselineMeasurement(10, {id: 'outlier', bpm: 180}),
|
||||
makeBaselineMeasurement(11, {
|
||||
id: 'health-context',
|
||||
source: 'healthKit',
|
||||
}),
|
||||
makeBaselineMeasurement(12, {id: 'moving', atRest: false}),
|
||||
makeBaselineMeasurement(13, {id: 'poor', signalQuality: 0.4}),
|
||||
makeBaselineMeasurement(0),
|
||||
];
|
||||
const result = calculateBaseline(measurements);
|
||||
|
||||
expect(result.baseline?.validMeasurementCount).toBe(11);
|
||||
expect(result.baseline?.medianBpm).toBe(60);
|
||||
expect(result.baseline?.bpmMad).toBeLessThanOrEqual(1);
|
||||
expect(result.rejectedMeasurements.map(item => item.reason)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'nonCameraSource',
|
||||
'notAtRest',
|
||||
'insufficientSignalQuality',
|
||||
'duplicateMeasurement',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('potential stress index', () => {
|
||||
const commonInput = {
|
||||
bpm: 75,
|
||||
estimatedRmssdMs: 20,
|
||||
prvReliable: true,
|
||||
validIntervalCount: 50,
|
||||
signalQuality: 0.95,
|
||||
usableDurationSeconds: 55,
|
||||
peakConfidence: 0.9,
|
||||
methodAgreementScore: 0.95,
|
||||
contextConsistency: 0.9,
|
||||
};
|
||||
|
||||
test('does not produce a personalized score before five measurements', () => {
|
||||
const baseline = calculatePersonalBaseline(makeBaselineMeasurements(4));
|
||||
const result = calculatePotentialStress({...commonInput, baseline});
|
||||
|
||||
expect(result.index).toBeNull();
|
||||
expect(result.status).toBe('baselineBuilding');
|
||||
expect(result.healthKitUsedForScore).toBe(false);
|
||||
});
|
||||
|
||||
test('produces a confidence-capped provisional result from five measurements', () => {
|
||||
const baseline = calculatePersonalBaseline(makeBaselineMeasurements(5));
|
||||
const result = calculatePotentialStress({...commonInput, baseline});
|
||||
|
||||
expect(result.index).not.toBeNull();
|
||||
expect(result.status).toBe('provisional');
|
||||
expect(result.confidence).toBeLessThanOrEqual(0.69);
|
||||
expect(result.limitations).toContain('provisionalBaseline');
|
||||
});
|
||||
|
||||
test('combines elevated BPM and lower reliable PRV against a personalized baseline', () => {
|
||||
const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10));
|
||||
const result = calculatePotentialStress({...commonInput, baseline});
|
||||
|
||||
expect(result.status).toBe('personalized');
|
||||
expect(result.index).toBeGreaterThan(80);
|
||||
expect(result.prvContribution).not.toBeNull();
|
||||
expect(result.confidence).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
test('uses BPM alone and strongly lowers confidence when PRV is unreliable', () => {
|
||||
const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10));
|
||||
const reliable = calculatePotentialStress({...commonInput, baseline});
|
||||
const withoutPrv = calculatePotentialStress({
|
||||
...commonInput,
|
||||
baseline,
|
||||
estimatedRmssdMs: null,
|
||||
prvReliable: false,
|
||||
validIntervalCount: 0,
|
||||
});
|
||||
|
||||
expect(withoutPrv.index).not.toBeNull();
|
||||
expect(withoutPrv.prvContribution).toBeNull();
|
||||
expect(withoutPrv.limitations).toContain('prvUnavailable');
|
||||
expect(withoutPrv.confidence).toBeLessThan(reliable.confidence);
|
||||
});
|
||||
|
||||
test('rejects signal quality below the scoring threshold', () => {
|
||||
const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10));
|
||||
const result = calculatePotentialStress({
|
||||
...commonInput,
|
||||
baseline,
|
||||
signalQuality: 0.64,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('unavailable');
|
||||
expect(result.index).toBeNull();
|
||||
expect(result.limitations).toContain('signalQualityTooLow');
|
||||
});
|
||||
|
||||
test('does not compare a non-resting measurement to the resting baseline', () => {
|
||||
const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10));
|
||||
const result = calculatePotentialStress({
|
||||
...commonInput,
|
||||
atRest: false,
|
||||
baseline,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('unavailable');
|
||||
expect(result.index).toBeNull();
|
||||
expect(result.baselineMeasurementCount).toBe(10);
|
||||
expect(result.limitations).toContain('measurementNotAtRest');
|
||||
});
|
||||
|
||||
test('keeps HealthKit weight locked at zero', () => {
|
||||
expect(() => createStressIndexConfiguration({healthKitWeight: 0.1})).toThrow(
|
||||
/healthKitWeight/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stress interpretation', () => {
|
||||
test.each([
|
||||
[30, 'low'],
|
||||
[31, 'moderate'],
|
||||
[60, 'moderate'],
|
||||
[61, 'high'],
|
||||
[80, 'high'],
|
||||
[81, 'veryHigh'],
|
||||
] as const)('maps %i to %s', (index, expectedLevel) => {
|
||||
const interpretation = interpretPotentialStressIndex(index);
|
||||
expect(interpretation.level).toBe(expectedLevel);
|
||||
expect(interpretation.disclaimer).toContain('ne constitue pas');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
evaluateHealthKitContext,
|
||||
evaluateHealthMetricSample,
|
||||
} from '../health/healthContextEvaluator';
|
||||
import type {
|
||||
HealthKitContext,
|
||||
HealthMetricSample,
|
||||
HealthMetricType,
|
||||
} from '../health/healthTypes';
|
||||
|
||||
const NOW = new Date('2026-07-10T12:00:00.000Z');
|
||||
|
||||
const sample = (
|
||||
type: HealthMetricType,
|
||||
endDate: string,
|
||||
sourceBundleIdentifier = 'com.apple.health',
|
||||
): HealthMetricSample => ({
|
||||
type,
|
||||
value: type === 'hrvSdnn' ? 48 : 64,
|
||||
unit: type === 'hrvSdnn' ? 'ms' : 'count/min',
|
||||
startDate: endDate,
|
||||
endDate,
|
||||
sourceName: 'Apple Health',
|
||||
sourceBundleIdentifier,
|
||||
deviceName: 'iPhone',
|
||||
});
|
||||
|
||||
const context = (overrides: Partial<HealthKitContext> = {}): HealthKitContext => ({
|
||||
available: true,
|
||||
authorizationRequestSettled: true,
|
||||
latestRestingHeartRate: sample(
|
||||
'restingHeartRate',
|
||||
'2026-07-10T08:00:00.000Z',
|
||||
),
|
||||
latestHrvSdnn: sample('hrvSdnn', '2026-07-10T07:30:00.000Z'),
|
||||
recentHeartRateSamples: [
|
||||
sample('heartRate', '2026-07-10T11:55:00.000Z', 'watch.source'),
|
||||
sample('heartRate', '2026-07-10T11:50:00.000Z', 'phone.source'),
|
||||
],
|
||||
contextConfidence: 1,
|
||||
fetchedAt: NOW.toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('HealthKit context evaluation', () => {
|
||||
test('never presents even fresh HealthKit data as the current camera measurement', () => {
|
||||
const evaluation = evaluateHealthMetricSample(
|
||||
sample('heartRate', '2026-07-10T11:59:00.000Z'),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(evaluation.relevance).toBe('freshContext');
|
||||
expect(evaluation.usableAsContext).toBe(true);
|
||||
expect(evaluation.canRepresentCurrentMeasurement).toBe(false);
|
||||
});
|
||||
|
||||
test('marks old heart-rate samples as stale', () => {
|
||||
const evaluation = evaluateHealthMetricSample(
|
||||
sample('heartRate', '2026-07-08T11:00:00.000Z'),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(evaluation.relevance).toBe('stale');
|
||||
expect(evaluation.confidence).toBe(0);
|
||||
});
|
||||
|
||||
test('keeps sources distinct and excludes HealthKit from the score', () => {
|
||||
const evaluation = evaluateHealthKitContext(context(), NOW);
|
||||
|
||||
expect(evaluation.hasUsableContext).toBe(true);
|
||||
expect(evaluation.healthKitUsedForScore).toBe(false);
|
||||
expect(evaluation.sourceBundleIdentifiers).toEqual([
|
||||
'com.apple.health',
|
||||
'phone.source',
|
||||
'watch.source',
|
||||
]);
|
||||
expect(evaluation.latestHrvSdnn?.sample.type).toBe('hrvSdnn');
|
||||
});
|
||||
|
||||
test('does not block the app when HealthKit is unavailable or not requested', () => {
|
||||
const unavailable = evaluateHealthKitContext(
|
||||
context({available: false, authorizationRequestSettled: false}),
|
||||
NOW,
|
||||
);
|
||||
const notRequested = evaluateHealthKitContext(
|
||||
context({authorizationRequestSettled: false}),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(unavailable.contextConfidence).toBe(0);
|
||||
expect(unavailable.issues).toContain('healthKitUnavailable');
|
||||
expect(notRequested.contextConfidence).toBe(0);
|
||||
expect(notRequested.issues).toContain(
|
||||
'healthKitAuthorizationNotRequested',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import {buildDailyTrends, buildFeelingCorrelation} from '../history/historyAnalytics';
|
||||
import type {StoredMeasurement} from '../storage/storageTypes';
|
||||
import {makeProcessingResult} from './syntheticMeasurements';
|
||||
|
||||
const makeMeasurement = (
|
||||
id: string,
|
||||
measuredAt: string,
|
||||
bpm: number,
|
||||
feeling: 1 | 2 | 3 | 4 | 5,
|
||||
): StoredMeasurement => ({
|
||||
schemaVersion: 1,
|
||||
id,
|
||||
measuredAt,
|
||||
acquisition: {cameraPosition: 'front', torchUsed: false},
|
||||
atRest: true,
|
||||
baselineEligible: true,
|
||||
cameraResult: (() => {
|
||||
const result = makeProcessingResult();
|
||||
return {...result, bpm: {...result.bpm, bpm}};
|
||||
})(),
|
||||
potentialStress: {
|
||||
index: 20, confidence: 0.8, status: 'provisional', bpmContribution: 0,
|
||||
prvContribution: 0, signalQuality: 0.9, baselineMeasurementCount: 5,
|
||||
healthKitUsedForScore: false, limitations: [],
|
||||
},
|
||||
healthKitContext: null,
|
||||
selfReport: {
|
||||
feeling, posture: 'seated', recentExercise: false, recentCaffeine: false,
|
||||
recentMeal: false, movementDuringMeasurement: false, phoneUnusuallyWarm: false,
|
||||
recordedAt: measuredAt,
|
||||
},
|
||||
});
|
||||
|
||||
describe('history analytics', () => {
|
||||
test('aggregates multiple measurements into chronological daily points', () => {
|
||||
const now = Date.parse('2026-07-17T12:00:00.000Z');
|
||||
const trends = buildDailyTrends([
|
||||
makeMeasurement('a', '2026-07-16T08:00:00.000Z', 70, 3),
|
||||
makeMeasurement('b', '2026-07-16T18:00:00.000Z', 80, 4),
|
||||
makeMeasurement('c', '2026-07-17T08:00:00.000Z', 90, 4),
|
||||
], 30, now);
|
||||
expect(trends.map(point => point.day)).toEqual(['2026-07-16', '2026-07-17']);
|
||||
expect(trends[0]).toMatchObject({bpm: 75, count: 2});
|
||||
});
|
||||
|
||||
test('groups descriptive BPM and RMSSD averages by feeling', () => {
|
||||
const correlation = buildFeelingCorrelation([
|
||||
makeMeasurement('a', '2026-07-16T08:00:00.000Z', 70, 4),
|
||||
makeMeasurement('b', '2026-07-16T18:00:00.000Z', 80, 4),
|
||||
]);
|
||||
expect(correlation).toHaveLength(1);
|
||||
expect(correlation[0]).toMatchObject({feeling: 4, averageBpm: 75, count: 2});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
createMeasurementConfiguration,
|
||||
DEFAULT_MEASUREMENT_CONFIGURATION,
|
||||
} from '../measurement/measurementConfiguration';
|
||||
|
||||
describe('measurement configuration', () => {
|
||||
test('uses a roughly sixty-second contact flow and a six-Hz UI cadence', () => {
|
||||
expect(
|
||||
DEFAULT_MEASUREMENT_CONFIGURATION.stabilizationSeconds +
|
||||
DEFAULT_MEASUREMENT_CONFIGURATION.measurementSeconds,
|
||||
).toBe(60);
|
||||
expect(
|
||||
DEFAULT_MEASUREMENT_CONFIGURATION.targetSampleRate /
|
||||
DEFAULT_MEASUREMENT_CONFIGURATION.emitEveryFrames,
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
test('rejects a band above Nyquist', () => {
|
||||
expect(() =>
|
||||
createMeasurementConfiguration({
|
||||
targetSampleRate: 30,
|
||||
maximumFrequencyHz: 16,
|
||||
}),
|
||||
).toThrow(/Nyquist/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type {NativePPGProcessingResult} from 'react-native-haptika-ppg';
|
||||
|
||||
import {mapNativeProcessingResult} from '../measurement/measurementCoordinator';
|
||||
|
||||
const nativeResult = (): NativePPGProcessingResult => ({
|
||||
valid: false,
|
||||
invalidReason: 'Signal exploitable insuffisant',
|
||||
bpm: {
|
||||
bpm: 72,
|
||||
temporalBpm: Number.NaN,
|
||||
frequencyBpm: 72.4,
|
||||
methodAgreementBpm: 0.4,
|
||||
validPeakCount: 19.7,
|
||||
rejectedPeakCount: 8.2,
|
||||
confidence: 0.51,
|
||||
selectedChannel: 'red-inverted',
|
||||
},
|
||||
prv: {
|
||||
estimatedRmssdMs: Number.POSITIVE_INFINITY,
|
||||
estimatedSdnnMs: 31,
|
||||
meanIpiMs: 833,
|
||||
medianIpiMs: 829,
|
||||
pnn50Percent: 7,
|
||||
rangeIpiMs: 118,
|
||||
modeIpiMs: 825,
|
||||
amo50Percent: 42,
|
||||
coefficientOfVariation: 0.05,
|
||||
validIntervalCount: 18.8,
|
||||
rejectedIntervalCount: 7.4,
|
||||
rejectedIntervalRatio: 0.28,
|
||||
reliable: false,
|
||||
},
|
||||
quality: {
|
||||
score: 0.43,
|
||||
level: 'invalid',
|
||||
usableDurationSeconds: 21.5,
|
||||
droppedFrameRatio: 0.08,
|
||||
saturationRatio: 0.12,
|
||||
rejectedIntervalRatio: 0.28,
|
||||
signalToNoiseRatio: 3.2,
|
||||
motionArtifactRatio: 0.31,
|
||||
methodAgreementScore: 0.6,
|
||||
timestampJitterRatio: 0.04,
|
||||
longestGapSeconds: 0.38,
|
||||
issues: ['motionArtifacts', 'futureNativeDiagnostic'],
|
||||
},
|
||||
startedAtMonotonicSeconds: 100,
|
||||
endedAtMonotonicSeconds: 160,
|
||||
rawSampleCount: 1_642.4,
|
||||
resampledSampleCount: 5_501.7,
|
||||
});
|
||||
|
||||
describe('measurementCoordinator diagnostics', () => {
|
||||
test('preserves acquisition diagnostics used to explain an invalid result', () => {
|
||||
const result = mapNativeProcessingResult(nativeResult());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
valid: false,
|
||||
invalidReason: 'Signal exploitable insuffisant',
|
||||
rawSampleCount: 1_642,
|
||||
resampledSampleCount: 5_502,
|
||||
quality: {
|
||||
usableDurationSeconds: 21.5,
|
||||
droppedFrameRatio: 0.08,
|
||||
saturationRatio: 0.12,
|
||||
rejectedIntervalRatio: 0.28,
|
||||
signalToNoiseRatio: 3.2,
|
||||
motionArtifactRatio: 0.31,
|
||||
timestampJitterRatio: 0.04,
|
||||
longestGapSeconds: 0.38,
|
||||
},
|
||||
bpm: {
|
||||
selectedChannel: 'red-inverted',
|
||||
validPeakCount: 20,
|
||||
rejectedPeakCount: 8,
|
||||
},
|
||||
prv: {
|
||||
validIntervalCount: 19,
|
||||
rejectedIntervalCount: 7,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes non-finite values and unknown native issues safely', () => {
|
||||
const result = mapNativeProcessingResult(nativeResult());
|
||||
|
||||
expect(result.bpm.temporalBpm).toBeNull();
|
||||
expect(result.prv.estimatedRmssdMs).toBeNull();
|
||||
expect(result.quality.issues).toEqual([
|
||||
'motionArtifacts',
|
||||
'nonFiniteProcessingResult',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
createInitialMeasurementMachine,
|
||||
transitionMeasurement,
|
||||
} from '../measurement/measurementMachine';
|
||||
import type {
|
||||
MeasurementEvent,
|
||||
MeasurementMachineSnapshot,
|
||||
} from '../measurement/measurementTypes';
|
||||
import {makeProcessingResult} from './syntheticMeasurements';
|
||||
|
||||
const send = (
|
||||
snapshot: MeasurementMachineSnapshot,
|
||||
...events: readonly MeasurementEvent[]
|
||||
): MeasurementMachineSnapshot =>
|
||||
events.reduce(transitionMeasurement, snapshot);
|
||||
|
||||
describe('measurement machine', () => {
|
||||
test('runs the complete camera-only lifecycle', () => {
|
||||
const result = makeProcessingResult();
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
{type: 'ACQUISITION_COMPLETE'},
|
||||
{type: 'PROCESSING_SUCCEEDED', result},
|
||||
);
|
||||
|
||||
expect(snapshot.value).toBe('completed');
|
||||
expect(snapshot.context.result).toEqual(result);
|
||||
expect(snapshot.context.attempt).toBe(1);
|
||||
});
|
||||
|
||||
test('continues when the optional HealthKit request fails', () => {
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START', requestHealthKit: true},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
);
|
||||
expect(snapshot.value).toBe('requestingHealthPermission');
|
||||
|
||||
const afterFailure = transitionMeasurement(snapshot, {
|
||||
type: 'HEALTH_PERMISSION_RESOLVED',
|
||||
status: 'requestFailed',
|
||||
});
|
||||
expect(afterFailure.value).toBe('preparingCamera');
|
||||
expect(afterFailure.context.healthPermission).toBe('requestFailed');
|
||||
});
|
||||
|
||||
test('a rejected camera permission retry returns through idle before requesting again', () => {
|
||||
const denied = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_DENIED'},
|
||||
);
|
||||
expect(denied.value).toBe('error');
|
||||
|
||||
const retrying = transitionMeasurement(denied, {type: 'RETRY'});
|
||||
expect(retrying.value).toBe('idle');
|
||||
expect(retrying.context.attempt).toBe(1);
|
||||
|
||||
const requestingAgain = transitionMeasurement(retrying, {type: 'START'});
|
||||
expect(requestingAgain.value).toBe('requestingCameraPermission');
|
||||
expect(requestingAgain.context.attempt).toBe(2);
|
||||
});
|
||||
|
||||
test.each(['waitingForFinger', 'measuring'] as const)(
|
||||
'routes a camera failure from %s to the global error state',
|
||||
activeState => {
|
||||
let active = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
);
|
||||
if (activeState === 'measuring') {
|
||||
active = send(
|
||||
active,
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
);
|
||||
}
|
||||
|
||||
const failed = transitionMeasurement(active, {
|
||||
type: 'FAIL',
|
||||
failure: {
|
||||
code: 'cameraRuntime',
|
||||
message: 'Camera runtime failure',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(failed.value).toBe('error');
|
||||
expect(failed.context.failure?.code).toBe('cameraRuntime');
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps stabilization and acquisition running when contact fluctuates', () => {
|
||||
const measuring = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
);
|
||||
expect(measuring.value).toBe('measuring');
|
||||
|
||||
const waiting = transitionMeasurement(measuring, {
|
||||
type: 'CONTACT_CHANGED',
|
||||
contactState: 'unstable',
|
||||
});
|
||||
expect(waiting.value).toBe('measuring');
|
||||
|
||||
const stillMeasuring = transitionMeasurement(waiting, {
|
||||
type: 'CONTACT_CHANGED',
|
||||
contactState: 'valid',
|
||||
});
|
||||
expect(stillMeasuring.value).toBe('measuring');
|
||||
});
|
||||
|
||||
test('allows stabilization to complete even when the latest frame is unstable', () => {
|
||||
const stabilizing = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'unstable'},
|
||||
);
|
||||
expect(stabilizing.value).toBe('stabilizing');
|
||||
expect(
|
||||
transitionMeasurement(stabilizing, {type: 'STABILIZATION_COMPLETE'}).value,
|
||||
).toBe('measuring');
|
||||
});
|
||||
|
||||
test.each(['serious', 'critical'] as const)(
|
||||
'stops immediately for a %s thermal state',
|
||||
thermalState => {
|
||||
const measuring = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
);
|
||||
const stopped = transitionMeasurement(measuring, {
|
||||
type: 'THERMAL_STATE_CHANGED',
|
||||
thermalState,
|
||||
});
|
||||
|
||||
expect(stopped.value).toBe('thermalStop');
|
||||
expect(stopped.context.failure?.code).toBe(
|
||||
thermalState === 'critical' ? 'thermalCritical' : 'thermalSerious',
|
||||
);
|
||||
expect(stopped.context.failure?.message).toMatch(/iPhone|chaud/);
|
||||
},
|
||||
);
|
||||
|
||||
test('cannot reset past an unsafe thermal state', () => {
|
||||
const stopped = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'THERMAL_STATE_CHANGED', thermalState: 'serious'},
|
||||
);
|
||||
|
||||
const reset = transitionMeasurement(stopped, {type: 'RESET'});
|
||||
expect(reset.value).toBe('thermalStop');
|
||||
|
||||
const cooled = transitionMeasurement(reset, {
|
||||
type: 'THERMAL_STATE_CHANGED',
|
||||
thermalState: 'fair',
|
||||
});
|
||||
expect(transitionMeasurement(cooled, {type: 'RETRY'}).value).toBe(
|
||||
'preparingCamera',
|
||||
);
|
||||
});
|
||||
|
||||
test('never completes an invalid native result', () => {
|
||||
const invalidResult = makeProcessingResult({
|
||||
valid: false,
|
||||
invalidReason: 'Signal insuffisant',
|
||||
quality: {
|
||||
...makeProcessingResult().quality,
|
||||
score: 0.4,
|
||||
level: 'invalid',
|
||||
},
|
||||
});
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
{type: 'ACQUISITION_COMPLETE'},
|
||||
{type: 'PROCESSING_SUCCEEDED', result: invalidResult},
|
||||
);
|
||||
|
||||
expect(snapshot.value).toBe('invalid');
|
||||
expect(snapshot.context.result?.valid).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps a conservative BPM when PRV quality is poor', () => {
|
||||
const partialResult = makeProcessingResult({
|
||||
valid: true,
|
||||
bpm: {
|
||||
...makeProcessingResult().bpm,
|
||||
bpm: 68,
|
||||
},
|
||||
quality: {
|
||||
...makeProcessingResult().quality,
|
||||
score: 0.52,
|
||||
level: 'poor',
|
||||
},
|
||||
prv: {
|
||||
...makeProcessingResult().prv,
|
||||
reliable: false,
|
||||
},
|
||||
});
|
||||
const snapshot = send(
|
||||
createInitialMeasurementMachine(),
|
||||
{type: 'START'},
|
||||
{type: 'CAMERA_PERMISSION_GRANTED'},
|
||||
{type: 'CAMERA_READY'},
|
||||
{type: 'CONTACT_CHANGED', contactState: 'valid'},
|
||||
{type: 'STABILIZATION_COMPLETE'},
|
||||
{type: 'ACQUISITION_COMPLETE'},
|
||||
{type: 'PROCESSING_SUCCEEDED', result: partialResult},
|
||||
);
|
||||
|
||||
expect(snapshot.value).toBe('completed');
|
||||
expect(snapshot.context.result?.bpm.bpm).toBe(68);
|
||||
expect(snapshot.context.result?.prv.reliable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {buildMetricInsights} from '../insights/metricInsights';
|
||||
import type {StoredMeasurement} from '../storage/storageTypes';
|
||||
import {makeProcessingResult} from './syntheticMeasurements';
|
||||
|
||||
const stored = (index: number, bpm: number, rmssd: number): StoredMeasurement => ({
|
||||
schemaVersion: 1,
|
||||
algorithmVersion: 'test',
|
||||
acquisition: {cameraPosition: 'front', torchUsed: false},
|
||||
id: `stored-${index}`,
|
||||
measuredAt: new Date(Date.UTC(2026, 6, index + 1, 8)).toISOString(),
|
||||
atRest: true,
|
||||
baselineEligible: true,
|
||||
cameraResult: makeProcessingResult({
|
||||
bpm: {...makeProcessingResult().bpm, bpm},
|
||||
prv: {...makeProcessingResult().prv, estimatedRmssdMs: rmssd},
|
||||
}),
|
||||
potentialStress: {
|
||||
index: null,
|
||||
status: 'baselineBuilding',
|
||||
confidence: 0,
|
||||
bpmContribution: null,
|
||||
prvContribution: null,
|
||||
signalQuality: 0.9,
|
||||
baselineMeasurementCount: 0,
|
||||
healthKitUsedForScore: false,
|
||||
limitations: [],
|
||||
},
|
||||
healthKitContext: null,
|
||||
selfReport: null,
|
||||
});
|
||||
|
||||
describe('buildMetricInsights', () => {
|
||||
it('uses real historical measurements and creates personal ranges after five values', () => {
|
||||
const history = [
|
||||
stored(0, 70, 40),
|
||||
stored(1, 71, 41),
|
||||
stored(2, 69, 39),
|
||||
stored(3, 72, 42),
|
||||
stored(4, 70, 40),
|
||||
];
|
||||
const insights = buildMetricInsights(history[4]!.cameraResult, history);
|
||||
const bpm = insights.find(insight => insight.key === 'bpm');
|
||||
const rmssd = insights.find(insight => insight.key === 'rmssd');
|
||||
|
||||
expect(bpm?.trend).toHaveLength(5);
|
||||
expect(bpm?.referenceRange).not.toBeNull();
|
||||
expect(bpm?.status).toBe('Dans votre repère habituel');
|
||||
expect(rmssd?.trend.map(point => point.value)).toEqual([40, 41, 39, 42, 40]);
|
||||
});
|
||||
|
||||
it('never publishes PRV insights when the current PRV is unreliable', () => {
|
||||
const current = makeProcessingResult({
|
||||
prv: {...makeProcessingResult().prv, reliable: false},
|
||||
});
|
||||
const insights = buildMetricInsights(current, []);
|
||||
|
||||
expect(insights.find(insight => insight.key === 'bpm')?.value).toBe(72);
|
||||
expect(insights.find(insight => insight.key === 'rmssd')?.value).toBeNull();
|
||||
expect(insights.find(insight => insight.key === 'rmssd')?.status).toBe(
|
||||
'Signal insuffisant',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
MeasurementSelfReport,
|
||||
StoredMeasurement,
|
||||
} from '../storage/storageTypes';
|
||||
import {makeProcessingResult} from './syntheticMeasurements';
|
||||
|
||||
const mockStore = new Map<string, string>();
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null),
|
||||
setItem: jest.fn(async (key: string, value: string) => {
|
||||
mockStore.set(key, value);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
exportMeasurementsAsCSV,
|
||||
exportMeasurementsAsJSON,
|
||||
measurementRepository,
|
||||
toBaselineMeasurement,
|
||||
} from '../storage/measurementRepository';
|
||||
import {settingsRepository} from '../storage/settingsRepository';
|
||||
|
||||
const makeStoredMeasurement = (id: string): StoredMeasurement => ({
|
||||
schemaVersion: 1,
|
||||
acquisition: {cameraPosition: 'front', torchUsed: false},
|
||||
id,
|
||||
measuredAt: '2026-07-10T08:00:00.000Z',
|
||||
atRest: true,
|
||||
baselineEligible: true,
|
||||
cameraResult: makeProcessingResult(),
|
||||
potentialStress: {
|
||||
index: null,
|
||||
confidence: 0,
|
||||
status: 'baselineBuilding',
|
||||
bpmContribution: null,
|
||||
prvContribution: null,
|
||||
signalQuality: 0.91,
|
||||
baselineMeasurementCount: 0,
|
||||
healthKitUsedForScore: false,
|
||||
limitations: ['baselineBuilding'],
|
||||
},
|
||||
healthKitContext: null,
|
||||
selfReport: null,
|
||||
});
|
||||
|
||||
describe('local storage repositories', () => {
|
||||
beforeEach(() => mockStore.clear());
|
||||
|
||||
it('stores numeric measurement results without camera frames', async () => {
|
||||
const measurement = makeStoredMeasurement('m-1');
|
||||
await measurementRepository.add(measurement);
|
||||
|
||||
const stored = await measurementRepository.getAll();
|
||||
expect(stored).toEqual([measurement]);
|
||||
expect(JSON.stringify(stored)).not.toContain('pixelBuffer');
|
||||
expect(JSON.stringify(stored)).not.toContain('image');
|
||||
});
|
||||
|
||||
it('stores self-report separately without changing the result', async () => {
|
||||
const measurement = makeStoredMeasurement('m-2');
|
||||
await measurementRepository.add(measurement);
|
||||
await measurementRepository.attachSelfReport('m-2', {
|
||||
feeling: 4,
|
||||
posture: 'seated',
|
||||
recentExercise: false,
|
||||
recentCaffeine: true,
|
||||
recentMeal: false,
|
||||
movementDuringMeasurement: false,
|
||||
phoneUnusuallyWarm: false,
|
||||
recordedAt: '2026-07-10T08:02:00.000Z',
|
||||
});
|
||||
|
||||
const [stored] = await measurementRepository.getAll();
|
||||
expect(stored?.selfReport?.feeling).toBe(4);
|
||||
expect(stored?.cameraResult).toEqual(measurement.cameraResult);
|
||||
expect(stored?.potentialStress).toEqual(measurement.potentialStress);
|
||||
expect(stored?.baselineEligible).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes a result write with an immediately submitted self-report', async () => {
|
||||
const measurement = makeStoredMeasurement('m-concurrent');
|
||||
const selfReport: MeasurementSelfReport = {
|
||||
feeling: 3,
|
||||
posture: 'seated',
|
||||
recentExercise: false,
|
||||
recentCaffeine: false,
|
||||
recentMeal: false,
|
||||
movementDuringMeasurement: false,
|
||||
phoneUnusuallyWarm: false,
|
||||
recordedAt: '2026-07-10T08:03:00.000Z',
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
measurementRepository.add(measurement),
|
||||
measurementRepository.attachSelfReport(measurement.id, selfReport),
|
||||
]);
|
||||
|
||||
expect(await measurementRepository.getAll()).toEqual([
|
||||
{...measurement, selfReport},
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes a contextually non-comparable measurement from future baselines', async () => {
|
||||
const measurement = makeStoredMeasurement('m-context');
|
||||
await measurementRepository.add(measurement);
|
||||
await measurementRepository.attachSelfReport(measurement.id, {
|
||||
feeling: 2,
|
||||
posture: 'seated',
|
||||
recentExercise: true,
|
||||
recentCaffeine: null,
|
||||
recentMeal: null,
|
||||
movementDuringMeasurement: false,
|
||||
phoneUnusuallyWarm: false,
|
||||
recordedAt: '2026-07-10T08:04:00.000Z',
|
||||
});
|
||||
|
||||
const [stored] = await measurementRepository.getAll();
|
||||
expect(stored?.baselineEligible).toBe(false);
|
||||
expect(stored && toBaselineMeasurement(stored).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('never re-enables a measurement explicitly excluded from the baseline', async () => {
|
||||
const measurement = {
|
||||
...makeStoredMeasurement('m-after-regulation'),
|
||||
baselineEligible: false,
|
||||
};
|
||||
await measurementRepository.add(measurement);
|
||||
await measurementRepository.attachSelfReport(measurement.id, {
|
||||
feeling: 2,
|
||||
posture: 'seated',
|
||||
recentExercise: false,
|
||||
recentCaffeine: false,
|
||||
recentMeal: false,
|
||||
movementDuringMeasurement: false,
|
||||
phoneUnusuallyWarm: false,
|
||||
recordedAt: '2026-07-10T08:05:00.000Z',
|
||||
});
|
||||
|
||||
const [stored] = await measurementRepository.getAll();
|
||||
expect(stored?.baselineEligible).toBe(false);
|
||||
});
|
||||
|
||||
it('drops malformed nested rows instead of exposing them to the history UI', async () => {
|
||||
const measurement = makeStoredMeasurement('m-valid');
|
||||
await measurementRepository.add(measurement);
|
||||
const [key] = [...mockStore.keys()];
|
||||
expect(key).toBeDefined();
|
||||
mockStore.set(
|
||||
key ?? 'missing',
|
||||
JSON.stringify([
|
||||
measurement,
|
||||
{...measurement, id: 'm-corrupt', cameraResult: {valid: true}},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(await measurementRepository.getAll()).toEqual([measurement]);
|
||||
});
|
||||
|
||||
it('keeps HealthKit preference optional and maps camera provenance', async () => {
|
||||
expect((await settingsRepository.get()).includeHealthKitContext).toBe(false);
|
||||
await settingsRepository.setIncludeHealthKitContext(true);
|
||||
expect((await settingsRepository.get()).includeHealthKitContext).toBe(true);
|
||||
|
||||
const baselineInput = toBaselineMeasurement(makeStoredMeasurement('m-3'));
|
||||
expect(baselineInput.source).toBe('cameraPPG');
|
||||
expect(baselineInput.bpm).toBe(72);
|
||||
expect(baselineInput.estimatedRmssdMs).toBe(42);
|
||||
expect(baselineInput.cameraPosition).toBe('front');
|
||||
});
|
||||
|
||||
it('reads, deletes and clears measurements without leaving duplicates', async () => {
|
||||
await measurementRepository.add(makeStoredMeasurement('m-delete'));
|
||||
await measurementRepository.add(makeStoredMeasurement('m-keep'));
|
||||
expect((await measurementRepository.getById('m-delete'))?.id).toBe('m-delete');
|
||||
await measurementRepository.deleteById('m-delete');
|
||||
expect((await measurementRepository.getAll()).map(item => item.id)).toEqual(['m-keep']);
|
||||
await measurementRepository.clearAll();
|
||||
expect(await measurementRepository.getAll()).toEqual([]);
|
||||
});
|
||||
|
||||
it('applies and persists the selected retention policy', async () => {
|
||||
const recent = {...makeStoredMeasurement('recent'), measuredAt: new Date().toISOString()};
|
||||
const old = {...makeStoredMeasurement('old'), measuredAt: '2020-01-01T08:00:00.000Z'};
|
||||
await measurementRepository.add(old);
|
||||
await measurementRepository.add(recent);
|
||||
await measurementRepository.setRetentionPolicy('30days');
|
||||
expect(await measurementRepository.getRetentionPolicy()).toBe('30days');
|
||||
expect((await measurementRepository.getAll()).map(item => item.id)).toEqual(['recent']);
|
||||
});
|
||||
|
||||
it('migrates legacy rows to explicit unknown camera provenance', async () => {
|
||||
const legacy: Record<string, unknown> = {...makeStoredMeasurement('legacy')};
|
||||
delete legacy.acquisition;
|
||||
const measurementKey = '@haptika/ppg/v1/measurements';
|
||||
mockStore.set(measurementKey, JSON.stringify([legacy]));
|
||||
expect((await measurementRepository.getAll())[0]?.acquisition).toEqual({
|
||||
cameraPosition: 'unknown',
|
||||
torchUsed: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('exports portable JSON and spreadsheet-friendly CSV without images', () => {
|
||||
const measurement = makeStoredMeasurement('m-export');
|
||||
const json = exportMeasurementsAsJSON([measurement]);
|
||||
const csv = exportMeasurementsAsCSV([measurement]);
|
||||
expect(JSON.parse(json).measurements[0].id).toBe('m-export');
|
||||
expect(csv).toContain('"camera"');
|
||||
expect(csv).toContain('"front"');
|
||||
expect(json).not.toContain('pixelBuffer');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {PPGProcessingResult} from '../measurement/measurementTypes';
|
||||
import type {BaselineMeasurement} from '../stress/baselineTypes';
|
||||
|
||||
const BPM_OFFSETS = [0, 1, -1, 0, 2, -2];
|
||||
const RMSSD_OFFSETS = [0, 2, -2, 1, -1, 0];
|
||||
|
||||
export const makeBaselineMeasurement = (
|
||||
index: number,
|
||||
overrides: Partial<BaselineMeasurement> = {},
|
||||
): BaselineMeasurement => ({
|
||||
id: `measurement-${index}`,
|
||||
source: 'cameraPPG',
|
||||
valid: true,
|
||||
measuredAt: new Date(Date.UTC(2026, 5, 1 + index, 8)).toISOString(),
|
||||
bpm: 60 + (BPM_OFFSETS[index % BPM_OFFSETS.length] ?? 0),
|
||||
estimatedRmssdMs:
|
||||
50 + (RMSSD_OFFSETS[index % RMSSD_OFFSETS.length] ?? 0),
|
||||
prvReliable: true,
|
||||
validIntervalCount: 48,
|
||||
signalQuality: 0.9,
|
||||
atRest: true,
|
||||
measurementHourLocal: 8,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeBaselineMeasurements = (
|
||||
count: number,
|
||||
): BaselineMeasurement[] =>
|
||||
Array.from({length: count}, (_, index) => makeBaselineMeasurement(index));
|
||||
|
||||
export const makeProcessingResult = (
|
||||
overrides: Partial<PPGProcessingResult> = {},
|
||||
): PPGProcessingResult => ({
|
||||
valid: true,
|
||||
invalidReason: null,
|
||||
bpm: {
|
||||
bpm: 72,
|
||||
temporalBpm: 71.8,
|
||||
frequencyBpm: 72.1,
|
||||
methodAgreementBpm: 0.3,
|
||||
validPeakCount: 64,
|
||||
rejectedPeakCount: 2,
|
||||
confidence: 0.92,
|
||||
selectedChannel: 'green',
|
||||
},
|
||||
prv: {
|
||||
estimatedRmssdMs: 42,
|
||||
estimatedSdnnMs: 48,
|
||||
medianIpiMs: 833,
|
||||
coefficientOfVariation: 0.06,
|
||||
validIntervalCount: 62,
|
||||
rejectedIntervalCount: 2,
|
||||
rejectedIntervalRatio: 0.03,
|
||||
reliable: true,
|
||||
},
|
||||
quality: {
|
||||
score: 0.91,
|
||||
level: 'good',
|
||||
usableDurationSeconds: 55,
|
||||
droppedFrameRatio: 0.01,
|
||||
saturationRatio: 0.03,
|
||||
rejectedIntervalRatio: 0.03,
|
||||
signalToNoiseRatio: 12,
|
||||
motionArtifactRatio: 0.02,
|
||||
methodAgreementScore: 0.95,
|
||||
timestampJitterRatio: 0.01,
|
||||
longestGapSeconds: 0.05,
|
||||
issues: [],
|
||||
},
|
||||
startedAtMonotonicSeconds: 1_000,
|
||||
endedAtMonotonicSeconds: 1_060,
|
||||
rawSampleCount: 1_800,
|
||||
resampledSampleCount: 1_650,
|
||||
...overrides,
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import {AppState} from 'react-native';
|
||||
import ReactTestRenderer from 'react-test-renderer';
|
||||
import {
|
||||
haptikaPPG,
|
||||
type PPGFrameUpdate,
|
||||
} from 'react-native-haptika-ppg';
|
||||
|
||||
import {
|
||||
usePPGMeasurement,
|
||||
type PPGMeasurementController,
|
||||
} from '../measurement/usePPGMeasurement';
|
||||
|
||||
jest.mock('react-native-haptika-ppg', () => ({
|
||||
haptikaPPG: {
|
||||
startMeasurement: jest.fn(),
|
||||
processFrame: jest.fn(),
|
||||
finishMeasurement: jest.fn(),
|
||||
cancelMeasurement: jest.fn(),
|
||||
getThermalState: jest.fn(() => 'nominal'),
|
||||
},
|
||||
haptikaHealthKit: {
|
||||
isAvailable: jest.fn(() => false),
|
||||
requestReadAuthorization: jest.fn(async () => false),
|
||||
},
|
||||
}));
|
||||
|
||||
const nativePPG = haptikaPPG as jest.Mocked<typeof haptikaPPG>;
|
||||
let controller: PPGMeasurementController;
|
||||
let renderer: ReactTestRenderer.ReactTestRenderer | null = null;
|
||||
let memoryWarningHandler: (() => void) | null = null;
|
||||
|
||||
function Harness(): null {
|
||||
controller = usePPGMeasurement();
|
||||
return null;
|
||||
}
|
||||
|
||||
const frameUpdate = (
|
||||
thermalState: PPGFrameUpdate['thermalState'],
|
||||
overrides: Partial<PPGFrameUpdate> = {},
|
||||
): PPGFrameUpdate => ({
|
||||
timestampSeconds: 1,
|
||||
contactState: 'absent',
|
||||
contactConfidence: 0,
|
||||
signalLevel: 0,
|
||||
saturationRatio: 0,
|
||||
motionArtifactScore: 0,
|
||||
collectedSampleCount: 0,
|
||||
usableDurationSeconds: 0,
|
||||
droppedFrameCount: 0,
|
||||
thermalState,
|
||||
waveformValue: 0,
|
||||
beatSequence: 0,
|
||||
acceptedBeats: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function startCamera(): Promise<void> {
|
||||
await ReactTestRenderer.act(async () => {
|
||||
await controller.start({
|
||||
includeHealthKitContext: false,
|
||||
sensitiveContact: true,
|
||||
requestCameraPermission: async () => true,
|
||||
});
|
||||
});
|
||||
ReactTestRenderer.act(() => controller.onCameraStarted());
|
||||
}
|
||||
|
||||
describe('usePPGMeasurement safety coordination', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
memoryWarningHandler = null;
|
||||
jest.spyOn(AppState, 'addEventListener').mockImplementation(
|
||||
((eventType: string, listener: () => void) => {
|
||||
if (eventType === 'memoryWarning') memoryWarningHandler = listener;
|
||||
return {remove: jest.fn()};
|
||||
}) as unknown as typeof AppState.addEventListener,
|
||||
);
|
||||
nativePPG.getThermalState.mockReturnValue('nominal');
|
||||
await ReactTestRenderer.act(async () => {
|
||||
renderer = ReactTestRenderer.create(<Harness />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (renderer !== null) {
|
||||
ReactTestRenderer.act(() => renderer?.unmount());
|
||||
renderer = null;
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('cancels the native session synchronously before publishing a camera failure', async () => {
|
||||
await startCamera();
|
||||
expect(nativePPG.startMeasurement).toHaveBeenCalledWith(
|
||||
expect.objectContaining({minimumContactConfidence: 0.45}),
|
||||
);
|
||||
expect(controller.snapshot.value).toBe('stabilizing');
|
||||
|
||||
ReactTestRenderer.act(() => {
|
||||
controller.onCameraError({
|
||||
code: 'cameraRuntime',
|
||||
message: 'Frame processing failed',
|
||||
});
|
||||
expect(nativePPG.cancelMeasurement).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(controller.snapshot.value).toBe('error');
|
||||
expect(controller.snapshot.context.failure?.code).toBe('cameraRuntime');
|
||||
});
|
||||
|
||||
test('re-reads native thermal state before leaving thermalStop', async () => {
|
||||
await startCamera();
|
||||
ReactTestRenderer.act(() => controller.onFrameUpdate(frameUpdate('serious')));
|
||||
expect(controller.snapshot.value).toBe('thermalStop');
|
||||
|
||||
nativePPG.getThermalState.mockReturnValueOnce('serious');
|
||||
ReactTestRenderer.act(() => controller.retry());
|
||||
expect(controller.snapshot.value).toBe('thermalStop');
|
||||
|
||||
nativePPG.getThermalState.mockReturnValueOnce('fair');
|
||||
ReactTestRenderer.act(() => controller.retry());
|
||||
expect(controller.snapshot.value).toBe('preparingCamera');
|
||||
expect(nativePPG.getThermalState).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('pauses useful progress without stopping capture when contact is unusable', async () => {
|
||||
await startCamera();
|
||||
|
||||
ReactTestRenderer.act(() =>
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: 10,
|
||||
contactState: 'valid',
|
||||
contactConfidence: 0.95,
|
||||
usableDurationSeconds: 0,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(controller.snapshot.value).toBe('stabilizing');
|
||||
|
||||
ReactTestRenderer.act(() =>
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: 12,
|
||||
contactState: 'unstable',
|
||||
contactConfidence: 0.15,
|
||||
usableDurationSeconds: 0,
|
||||
motionArtifactScore: 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(controller.snapshot.value).toBe('stabilizing');
|
||||
expect(controller.phaseProgress).toBe(0);
|
||||
|
||||
ReactTestRenderer.act(() =>
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: 15.1,
|
||||
contactState: 'absent',
|
||||
usableDurationSeconds: 0,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(controller.snapshot.value).toBe('stabilizing');
|
||||
|
||||
ReactTestRenderer.act(() => {
|
||||
for (let timestamp = 16; timestamp <= 21.5; timestamp += 0.5) {
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: timestamp,
|
||||
contactState: 'valid',
|
||||
contactConfidence: 0.9,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(controller.snapshot.value).toBe('measuring');
|
||||
|
||||
ReactTestRenderer.act(() =>
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: 32.1,
|
||||
contactState: 'overexposed',
|
||||
contactConfidence: 0.05,
|
||||
usableDurationSeconds: 0,
|
||||
saturationRatio: 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const firstAcquisitionProgress = controller.phaseProgress;
|
||||
expect(firstAcquisitionProgress).toBe(0);
|
||||
expect(controller.snapshot.value).toBe('measuring');
|
||||
|
||||
ReactTestRenderer.act(() =>
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: 54.1,
|
||||
contactState: 'tooMuchPressure',
|
||||
contactConfidence: 0.1,
|
||||
usableDurationSeconds: 0,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(controller.snapshot.value).toBe('measuring');
|
||||
expect(controller.phaseProgress).toBe(firstAcquisitionProgress);
|
||||
expect(nativePPG.cancelMeasurement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('finishes after fifty detected beats and the minimum acquisition time', async () => {
|
||||
nativePPG.finishMeasurement.mockImplementationOnce(() => {
|
||||
throw new Error('synthetic processing stop');
|
||||
});
|
||||
await startCamera();
|
||||
ReactTestRenderer.act(() => {
|
||||
for (let timestamp = 10; timestamp <= 15.5; timestamp += 0.5) {
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: timestamp,
|
||||
contactState: 'valid',
|
||||
contactConfidence: 0.95,
|
||||
beatSequence: 4,
|
||||
usableDurationSeconds: timestamp - 10,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(controller.snapshot.value).toBe('measuring');
|
||||
|
||||
ReactTestRenderer.act(() =>
|
||||
controller.onFrameUpdate(
|
||||
frameUpdate('nominal', {
|
||||
timestampSeconds: 60.7,
|
||||
contactState: 'unstable',
|
||||
beatSequence: 49,
|
||||
usableDurationSeconds: 50.7,
|
||||
collectedSampleCount: 1_500,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(controller.phaseProgress).toBe(1);
|
||||
expect(nativePPG.finishMeasurement).toHaveBeenCalledTimes(1);
|
||||
expect(controller.snapshot.value).not.toBe('measuring');
|
||||
});
|
||||
|
||||
test('stops and releases the native camera session on memory pressure', async () => {
|
||||
await startCamera();
|
||||
expect(controller.snapshot.value).toBe('stabilizing');
|
||||
expect(memoryWarningHandler).not.toBeNull();
|
||||
|
||||
ReactTestRenderer.act(() => memoryWarningHandler?.());
|
||||
|
||||
expect(nativePPG.cancelMeasurement).toHaveBeenCalledTimes(1);
|
||||
expect(controller.snapshot.value).toBe('error');
|
||||
expect(controller.snapshot.context.failure?.code).toBe('memoryPressure');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
WatchMeasurementComparisonV1,
|
||||
WatchMeasurementSummaryV1,
|
||||
WatchRegulationSessionV1,
|
||||
} from './watchTypes';
|
||||
|
||||
export interface WatchDailyTrendV1 {
|
||||
day: string;
|
||||
averageBpm: number;
|
||||
averageQuality: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const average = (values: readonly number[]): number =>
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
|
||||
export function buildWatchDailyTrend(
|
||||
measurements: readonly WatchMeasurementSummaryV1[],
|
||||
days = 7,
|
||||
now = Date.now(),
|
||||
): WatchDailyTrendV1[] {
|
||||
const cutoff = now - days * 86_400_000;
|
||||
const groups = new Map<string, WatchMeasurementSummaryV1[]>();
|
||||
measurements.filter(item => item.valid && item.averageBpm !== null && Date.parse(item.endedAt) >= cutoff).forEach(item => {
|
||||
const day = item.endedAt.slice(0, 10);
|
||||
groups.set(day, [...(groups.get(day) ?? []), item]);
|
||||
});
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, items]) => ({
|
||||
day,
|
||||
averageBpm: average(items.map(item => item.averageBpm as number)),
|
||||
averageQuality: average(items.map(item => item.quality)),
|
||||
count: items.length,
|
||||
}));
|
||||
}
|
||||
|
||||
export function compareWatchRegulationSession(
|
||||
session: WatchRegulationSessionV1,
|
||||
measurements: readonly WatchMeasurementSummaryV1[],
|
||||
): WatchMeasurementComparisonV1 | null {
|
||||
if (session.state !== 'completed' || session.beforeMeasurementId === null || session.afterMeasurementId === null) return null;
|
||||
const before = measurements.find(item => item.id === session.beforeMeasurementId);
|
||||
const after = measurements.find(item => item.id === session.afterMeasurementId);
|
||||
if (before === undefined || after === undefined || !before.valid || !after.valid || before.averageBpm === null || after.averageBpm === null) return null;
|
||||
const feeling = before.feeling === null || after.feeling === null ? null : {
|
||||
before: before.feeling.score,
|
||||
after: after.feeling.score,
|
||||
delta: after.feeling.score - before.feeling.score,
|
||||
};
|
||||
return {
|
||||
beforeMeasurementId: before.id,
|
||||
afterMeasurementId: after.id,
|
||||
bpm: {before: before.averageBpm, after: after.averageBpm, delta: after.averageBpm - before.averageBpm},
|
||||
quality: {before: before.quality, after: after.quality, delta: after.quality - before.quality},
|
||||
feeling,
|
||||
prvAvailable: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {median, medianAbsoluteDeviation} from '../ppg/stress/baselineCalculator';
|
||||
import type {WatchMeasurementSummaryV1} from './watchTypes';
|
||||
|
||||
export type WatchBaselineStatus = 'building' | 'provisional' | 'personalized';
|
||||
export type WatchBaselineInterpretation = 'habitual' | 'slightlyDifferent' | 'clearlyDifferent';
|
||||
|
||||
export interface WatchBaselineV1 {
|
||||
source: 'appleWatchOptical';
|
||||
status: WatchBaselineStatus;
|
||||
validMeasurementCount: number;
|
||||
medianBpm: number;
|
||||
bpmMad: number;
|
||||
medianQuality: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WatchBaselineComparisonV1 {
|
||||
interpretation: WatchBaselineInterpretation;
|
||||
deltaBpm: number;
|
||||
robustDistance: number;
|
||||
}
|
||||
|
||||
export function calculateWatchBaseline(
|
||||
measurements: readonly WatchMeasurementSummaryV1[],
|
||||
): WatchBaselineV1 | null {
|
||||
const unique = new Map(measurements.map(item => [item.id, item]));
|
||||
const accepted = [...unique.values()].filter(item =>
|
||||
item.valid && item.context.atRest && item.quality >= 0.65 &&
|
||||
item.averageBpm !== null && item.averageBpm >= 35 && item.averageBpm <= 220 && Number.isFinite(Date.parse(item.endedAt)),
|
||||
);
|
||||
if (accepted.length === 0) return null;
|
||||
const bpmValues = accepted.map(item => item.averageBpm as number);
|
||||
const center = median(bpmValues) as number;
|
||||
const mad = medianAbsoluteDeviation(bpmValues, center) as number;
|
||||
return {
|
||||
source: 'appleWatchOptical',
|
||||
status: accepted.length >= 10 ? 'personalized' : accepted.length >= 5 ? 'provisional' : 'building',
|
||||
validMeasurementCount: accepted.length,
|
||||
medianBpm: center,
|
||||
bpmMad: mad,
|
||||
medianQuality: median(accepted.map(item => item.quality)) as number,
|
||||
updatedAt: new Date(Math.max(...accepted.map(item => Date.parse(item.updatedAt)))).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function compareWithWatchBaseline(
|
||||
averageBpm: number,
|
||||
baseline: WatchBaselineV1,
|
||||
): WatchBaselineComparisonV1 {
|
||||
const deltaBpm = averageBpm - baseline.medianBpm;
|
||||
// A 3 BPM floor avoids classifying tiny changes as exceptional when MAD is zero.
|
||||
const robustScale = Math.max(3, baseline.bpmMad * 1.4826);
|
||||
const robustDistance = Math.abs(deltaBpm) / robustScale;
|
||||
return {
|
||||
deltaBpm,
|
||||
robustDistance,
|
||||
interpretation: robustDistance <= 1.5
|
||||
? 'habitual'
|
||||
: robustDistance <= 3
|
||||
? 'slightlyDifferent'
|
||||
: 'clearlyDifferent',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import fixtures from '../../../shared/watch-sync-fixtures.v1.json';
|
||||
import {
|
||||
parseWatchEnvelope,
|
||||
parseWatchMeasurement,
|
||||
parseWatchTombstone,
|
||||
} from './watchValidation';
|
||||
|
||||
describe('Watch sync contract V1 fixtures', () => {
|
||||
it('accepts the common measurement envelope', () => {
|
||||
const envelope = parseWatchEnvelope(fixtures.measurementEnvelope);
|
||||
expect(envelope).not.toBeNull();
|
||||
const measurement = parseWatchMeasurement(envelope?.payload);
|
||||
expect(measurement?.source).toBe('appleWatchOptical');
|
||||
expect(measurement?.averageBpm).toBe(71.5);
|
||||
expect(measurement?.samples).toHaveLength(8);
|
||||
expect(measurement?.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a deletion tombstone using the same wrapper', () => {
|
||||
const envelope = parseWatchEnvelope(fixtures.tombstoneEnvelope);
|
||||
expect(envelope?.kind).toBe('tombstone');
|
||||
expect(parseWatchTombstone(envelope?.payload)?.entityKind).toBe(
|
||||
'measurement',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import fixtures from '../../../shared/watch-sync-fixtures.v1.json';
|
||||
import type {WatchMeasurementSummaryV1, WatchSyncEnvelopeV1} from './watchTypes';
|
||||
|
||||
const mockStore = new Map<string, string>();
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null),
|
||||
setItem: jest.fn(async (key: string, value: string) => { mockStore.set(key, value); }),
|
||||
},
|
||||
}));
|
||||
|
||||
import {buildWatchDailyTrend} from './watchAnalytics';
|
||||
import {calculateWatchBaseline, compareWithWatchBaseline} from './watchBaseline';
|
||||
import {watchRepository} from './watchRepository';
|
||||
|
||||
const fixtureEnvelope = fixtures.measurementEnvelope as WatchSyncEnvelopeV1<WatchMeasurementSummaryV1>;
|
||||
|
||||
describe('Watch repository and baseline V1', () => {
|
||||
beforeEach(() => mockStore.clear());
|
||||
|
||||
test('imports a Watch measurement idempotently without touching camera storage', async () => {
|
||||
expect((await watchRepository.importEnvelope(fixtureEnvelope)).status).toBe('applied');
|
||||
expect((await watchRepository.importEnvelope(fixtureEnvelope)).status).toBe('duplicate');
|
||||
expect((await watchRepository.getMeasurements())[0]?.averageBpm).toBe(71.5);
|
||||
expect(mockStore.has('@haptika/ppg/v1/measurements')).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves upserts by updatedAt and lets an equally recent tombstone win', async () => {
|
||||
await watchRepository.importEnvelope(fixtureEnvelope);
|
||||
const older = {
|
||||
...fixtureEnvelope,
|
||||
eventId: 'older-event',
|
||||
payload: {...fixtureEnvelope.payload, updatedAt: '2026-07-20T08:59:59.000Z', averageBpm: 70},
|
||||
};
|
||||
expect((await watchRepository.importEnvelope(older)).status).toBe('stale');
|
||||
expect((await watchRepository.importEnvelope(fixtures.tombstoneEnvelope)).status).toBe('applied');
|
||||
expect(await watchRepository.getMeasurements()).toEqual([]);
|
||||
const resurrect = {...fixtureEnvelope, eventId: 'resurrect-after-delete'};
|
||||
expect((await watchRepository.importEnvelope(resurrect)).status).toBe('stale');
|
||||
});
|
||||
|
||||
test('accepts an invalid Watch result with unavailable BPM metrics', async () => {
|
||||
const invalid = {
|
||||
...fixtureEnvelope,
|
||||
eventId: 'invalid-result-event',
|
||||
payload: {
|
||||
...fixtureEnvelope.payload,
|
||||
id: 'invalid-result',
|
||||
currentBpm: null,
|
||||
averageBpm: null,
|
||||
minimumBpm: null,
|
||||
maximumBpm: null,
|
||||
samples: [],
|
||||
coverage: 0,
|
||||
quality: 0,
|
||||
valid: false,
|
||||
invalidReason: 'insufficientSamples',
|
||||
},
|
||||
};
|
||||
expect((await watchRepository.importEnvelope(invalid)).status).toBe('applied');
|
||||
expect((await watchRepository.getMeasurements())[0]?.valid).toBe(false);
|
||||
});
|
||||
|
||||
test('builds a separate robust Watch baseline after 5 and 10 measurements', () => {
|
||||
const base = fixtureEnvelope.payload;
|
||||
const make = (index: number): WatchMeasurementSummaryV1 => ({
|
||||
...base,
|
||||
id: `watch-${index}`,
|
||||
updatedAt: `2026-07-${String(10 + index).padStart(2, '0')}T09:00:00.000Z`,
|
||||
endedAt: `2026-07-${String(10 + index).padStart(2, '0')}T09:00:00.000Z`,
|
||||
averageBpm: 70 + (index % 3), minimumBpm: 68, maximumBpm: 75,
|
||||
});
|
||||
const provisional = calculateWatchBaseline(Array.from({length: 5}, (_, index) => make(index)));
|
||||
expect(provisional?.status).toBe('provisional');
|
||||
const personalized = calculateWatchBaseline(Array.from({length: 10}, (_, index) => make(index)));
|
||||
expect(personalized?.status).toBe('personalized');
|
||||
expect(personalized && compareWithWatchBaseline(71, personalized).interpretation).toBe('habitual');
|
||||
expect(personalized && compareWithWatchBaseline(100, personalized).interpretation).toBe('clearlyDifferent');
|
||||
});
|
||||
|
||||
test('builds Watch trends without PRV fields', () => {
|
||||
const trends = buildWatchDailyTrend([fixtureEnvelope.payload], 7, Date.parse('2026-07-21T09:00:00.000Z'));
|
||||
expect(trends).toEqual([{day: '2026-07-20', averageBpm: 71.5, averageQuality: 0.83, count: 1}]);
|
||||
expect(trends[0]).not.toHaveProperty('rmssdMs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import {readJSON, storageKeys, writeJSON} from '../ppg/storage/database';
|
||||
import type {
|
||||
WatchMeasurementFeelingV1,
|
||||
WatchMeasurementSummaryV1,
|
||||
WatchRegulationSessionV1,
|
||||
WatchSyncEnvelopeV1,
|
||||
WatchTombstoneV1,
|
||||
} from './watchTypes';
|
||||
import {
|
||||
parseWatchEnvelope,
|
||||
parseWatchMeasurement,
|
||||
parseWatchRegulationSession,
|
||||
parseWatchTombstone,
|
||||
} from './watchValidation';
|
||||
|
||||
export type WatchImportStatus = 'applied' | 'duplicate' | 'stale' | 'invalid' | 'ignored';
|
||||
|
||||
export interface WatchImportResult {
|
||||
eventId: string | null;
|
||||
status: WatchImportStatus;
|
||||
}
|
||||
|
||||
let mutationQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
const enqueue = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const pending = mutationQueue.then(operation);
|
||||
mutationQueue = pending.then(() => undefined, () => undefined);
|
||||
return pending;
|
||||
};
|
||||
|
||||
const updatedAt = (value: {updatedAt: string}): number => Date.parse(value.updatedAt);
|
||||
|
||||
async function readMeasurements(): Promise<WatchMeasurementSummaryV1[]> {
|
||||
const raw = await readJSON<unknown>(storageKeys.watchMeasurements, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const unique = new Map<string, WatchMeasurementSummaryV1>();
|
||||
raw.forEach(value => {
|
||||
const parsed = parseWatchMeasurement(value);
|
||||
const previous = parsed === null ? undefined : unique.get(parsed.id);
|
||||
if (parsed !== null && (previous === undefined || updatedAt(parsed) > updatedAt(previous))) {
|
||||
unique.set(parsed.id, parsed);
|
||||
}
|
||||
});
|
||||
return [...unique.values()].sort((a, b) => Date.parse(b.endedAt) - Date.parse(a.endedAt));
|
||||
}
|
||||
|
||||
async function readSessions(): Promise<WatchRegulationSessionV1[]> {
|
||||
const raw = await readJSON<unknown>(storageKeys.watchRegulationSessions, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const unique = new Map<string, WatchRegulationSessionV1>();
|
||||
raw.forEach(value => {
|
||||
const parsed = parseWatchRegulationSession(value);
|
||||
const previous = parsed === null ? undefined : unique.get(parsed.id);
|
||||
if (parsed !== null && (previous === undefined || updatedAt(parsed) > updatedAt(previous))) {
|
||||
unique.set(parsed.id, parsed);
|
||||
}
|
||||
});
|
||||
return [...unique.values()].sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt));
|
||||
}
|
||||
|
||||
async function readTombstones(): Promise<WatchTombstoneV1[]> {
|
||||
const raw = await readJSON<unknown>(storageKeys.watchTombstones, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const unique = new Map<string, WatchTombstoneV1>();
|
||||
raw.forEach(value => {
|
||||
const parsed = parseWatchTombstone(value);
|
||||
const previous = parsed === null ? undefined : unique.get(`${parsed.entityKind}:${parsed.entityId}`);
|
||||
if (parsed !== null && (previous === undefined || updatedAt(parsed) >= updatedAt(previous))) {
|
||||
unique.set(`${parsed.entityKind}:${parsed.entityId}`, parsed);
|
||||
}
|
||||
});
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
async function processedEvents(): Promise<string[]> {
|
||||
const raw = await readJSON<unknown>(storageKeys.watchProcessedEvents, []);
|
||||
return Array.isArray(raw) ? raw.filter((item): item is string => typeof item === 'string') : [];
|
||||
}
|
||||
|
||||
async function markProcessed(eventId: string, current: readonly string[]): Promise<void> {
|
||||
await writeJSON(storageKeys.watchProcessedEvents, [eventId, ...current.filter(id => id !== eventId)].slice(0, 1_000));
|
||||
}
|
||||
|
||||
function tombstoneFor(
|
||||
tombstones: readonly WatchTombstoneV1[],
|
||||
entityKind: WatchTombstoneV1['entityKind'],
|
||||
entityId: string,
|
||||
): WatchTombstoneV1 | undefined {
|
||||
return tombstones.find(item => item.entityKind === entityKind && item.entityId === entityId);
|
||||
}
|
||||
|
||||
async function upsertMeasurement(
|
||||
candidate: WatchMeasurementSummaryV1,
|
||||
tombstones: readonly WatchTombstoneV1[],
|
||||
): Promise<WatchImportStatus> {
|
||||
const tombstone = tombstoneFor(tombstones, 'measurement', candidate.id);
|
||||
if (tombstone !== undefined && updatedAt(tombstone) >= updatedAt(candidate)) return 'stale';
|
||||
const current = await readMeasurements();
|
||||
const previous = current.find(item => item.id === candidate.id);
|
||||
if (previous !== undefined && updatedAt(previous) >= updatedAt(candidate)) return 'stale';
|
||||
await writeJSON(storageKeys.watchMeasurements, [candidate, ...current.filter(item => item.id !== candidate.id)]);
|
||||
return 'applied';
|
||||
}
|
||||
|
||||
async function upsertSession(
|
||||
candidate: WatchRegulationSessionV1,
|
||||
tombstones: readonly WatchTombstoneV1[],
|
||||
): Promise<WatchImportStatus> {
|
||||
const tombstone = tombstoneFor(tombstones, 'regulationSession', candidate.id);
|
||||
if (tombstone !== undefined && updatedAt(tombstone) >= updatedAt(candidate)) return 'stale';
|
||||
const current = await readSessions();
|
||||
const previous = current.find(item => item.id === candidate.id);
|
||||
if (previous !== undefined && updatedAt(previous) >= updatedAt(candidate)) return 'stale';
|
||||
await writeJSON(storageKeys.watchRegulationSessions, [candidate, ...current.filter(item => item.id !== candidate.id)]);
|
||||
return 'applied';
|
||||
}
|
||||
|
||||
async function applyFeeling(
|
||||
payload: unknown,
|
||||
tombstones: readonly WatchTombstoneV1[],
|
||||
): Promise<WatchImportStatus> {
|
||||
if (typeof payload !== 'object' || payload === null) return 'invalid';
|
||||
const value = payload as Record<string, unknown>;
|
||||
if (typeof value.measurementId !== 'string' || typeof value.updatedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(value.updatedAt)) || typeof value.feeling !== 'object' || value.feeling === null) return 'invalid';
|
||||
const feeling = value.feeling as Record<string, unknown>;
|
||||
if (![1, 2, 3, 4, 5].includes(Number(feeling.score)) || typeof feeling.recordedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(feeling.recordedAt))) return 'invalid';
|
||||
const current = await readMeasurements();
|
||||
const measurement = current.find(item => item.id === value.measurementId);
|
||||
if (measurement === undefined || tombstoneFor(tombstones, 'measurement', measurement.id) !== undefined) return 'ignored';
|
||||
if (updatedAt(measurement) > Date.parse(value.updatedAt)) return 'stale';
|
||||
const next: WatchMeasurementSummaryV1 = {
|
||||
...measurement,
|
||||
feeling: feeling as unknown as WatchMeasurementFeelingV1,
|
||||
updatedAt: value.updatedAt,
|
||||
};
|
||||
await writeJSON(storageKeys.watchMeasurements, [next, ...current.filter(item => item.id !== next.id)]);
|
||||
return 'applied';
|
||||
}
|
||||
|
||||
async function applyTombstone(candidate: WatchTombstoneV1): Promise<WatchImportStatus> {
|
||||
const tombstones = await readTombstones();
|
||||
const previous = tombstoneFor(tombstones, candidate.entityKind, candidate.entityId);
|
||||
if (previous !== undefined && updatedAt(previous) > updatedAt(candidate)) return 'stale';
|
||||
const next = [candidate, ...tombstones.filter(item => item.entityKind !== candidate.entityKind || item.entityId !== candidate.entityId)];
|
||||
await writeJSON(storageKeys.watchTombstones, next);
|
||||
if (candidate.entityKind === 'measurement') {
|
||||
const measurements = await readMeasurements();
|
||||
await writeJSON(storageKeys.watchMeasurements, measurements.filter(item => item.id !== candidate.entityId));
|
||||
} else {
|
||||
const sessions = await readSessions();
|
||||
await writeJSON(storageKeys.watchRegulationSessions, sessions.filter(item => item.id !== candidate.entityId));
|
||||
}
|
||||
return 'applied';
|
||||
}
|
||||
|
||||
export const watchRepository = {
|
||||
async getMeasurements(): Promise<WatchMeasurementSummaryV1[]> {
|
||||
await mutationQueue;
|
||||
const [items, tombstones] = await Promise.all([readMeasurements(), readTombstones()]);
|
||||
return items.filter(item => tombstoneFor(tombstones, 'measurement', item.id) === undefined);
|
||||
},
|
||||
|
||||
async getRegulationSessions(): Promise<WatchRegulationSessionV1[]> {
|
||||
await mutationQueue;
|
||||
const [items, tombstones] = await Promise.all([readSessions(), readTombstones()]);
|
||||
return items.filter(item => tombstoneFor(tombstones, 'regulationSession', item.id) === undefined);
|
||||
},
|
||||
|
||||
async deleteMeasurement(id: string, deletedAt = new Date().toISOString()): Promise<WatchTombstoneV1> {
|
||||
return enqueue(async () => {
|
||||
const tombstone: WatchTombstoneV1 = {schemaVersion: 1, entityId: id, entityKind: 'measurement', deletedAt, updatedAt: deletedAt};
|
||||
await applyTombstone(tombstone);
|
||||
return tombstone;
|
||||
});
|
||||
},
|
||||
|
||||
async clearAll(deletedAt = new Date().toISOString()): Promise<WatchTombstoneV1[]> {
|
||||
return enqueue(async () => {
|
||||
const [measurements, sessions] = await Promise.all([readMeasurements(), readSessions()]);
|
||||
const created = [
|
||||
...measurements.map(item => ({schemaVersion: 1 as const, entityId: item.id, entityKind: 'measurement' as const, deletedAt, updatedAt: deletedAt})),
|
||||
...sessions.map(item => ({schemaVersion: 1 as const, entityId: item.id, entityKind: 'regulationSession' as const, deletedAt, updatedAt: deletedAt})),
|
||||
];
|
||||
await writeJSON(storageKeys.watchTombstones, created);
|
||||
await Promise.all([writeJSON(storageKeys.watchMeasurements, []), writeJSON(storageKeys.watchRegulationSessions, [])]);
|
||||
return created;
|
||||
});
|
||||
},
|
||||
|
||||
async importEnvelope(input: WatchSyncEnvelopeV1 | unknown): Promise<WatchImportResult> {
|
||||
return enqueue(async () => {
|
||||
const envelope = parseWatchEnvelope(input);
|
||||
if (envelope === null) return {eventId: null, status: 'invalid'};
|
||||
const processed = await processedEvents();
|
||||
if (processed.includes(envelope.eventId)) return {eventId: envelope.eventId, status: 'duplicate'};
|
||||
const tombstones = await readTombstones();
|
||||
let status: WatchImportStatus;
|
||||
switch (envelope.kind) {
|
||||
case 'measurementUpsert': {
|
||||
const measurement = parseWatchMeasurement(envelope.payload);
|
||||
status = measurement === null ? 'invalid' : await upsertMeasurement(measurement, tombstones);
|
||||
break;
|
||||
}
|
||||
case 'regulationSessionUpsert': {
|
||||
const session = parseWatchRegulationSession(envelope.payload);
|
||||
status = session === null ? 'invalid' : await upsertSession(session, tombstones);
|
||||
break;
|
||||
}
|
||||
case 'feelingUpsert': status = await applyFeeling(envelope.payload, tombstones); break;
|
||||
case 'tombstone': {
|
||||
const tombstone = parseWatchTombstone(envelope.payload);
|
||||
status = tombstone === null ? 'invalid' : await applyTombstone(tombstone);
|
||||
break;
|
||||
}
|
||||
default: status = 'ignored';
|
||||
}
|
||||
// Invalid payloads are intentionally not acknowledged as processed so a fixed retry can win.
|
||||
if (status !== 'invalid') await markProcessed(envelope.eventId, processed);
|
||||
return {eventId: envelope.eventId, status};
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import fixtures from '../../../shared/watch-sync-fixtures.v1.json';
|
||||
|
||||
const mockStore = new Map<string, string>();
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null),
|
||||
setItem: jest.fn(async (key: string, value: string) => { mockStore.set(key, value); }),
|
||||
},
|
||||
}));
|
||||
jest.mock('react-native-haptika-ppg', () => ({haptikaCardiacMirror: {}}));
|
||||
|
||||
import {WatchSyncCoordinator, type WatchNativeTransport} from './watchService';
|
||||
|
||||
describe('Watch sync coordinator', () => {
|
||||
beforeEach(() => mockStore.clear());
|
||||
|
||||
test('imports, acknowledges, then recognizes a replay as duplicate', async () => {
|
||||
const acknowledgeWatchEvents = jest.fn();
|
||||
const transport: WatchNativeTransport = {
|
||||
startWatchSession: jest.fn(),
|
||||
watchConnectionState: jest.fn(async () => ({supported: true, paired: true, watchAppInstalled: true, reachable: false, activationState: 'activated'})),
|
||||
drainWatchEvents: jest.fn(async () => [JSON.stringify(fixtures.measurementEnvelope)]),
|
||||
acknowledgeWatchEvents,
|
||||
sendWatchPreferences: jest.fn(async () => true),
|
||||
sendWatchCommand: jest.fn(async () => true),
|
||||
transferWatchEvent: jest.fn(async () => true),
|
||||
};
|
||||
const coordinator = new WatchSyncCoordinator(transport);
|
||||
expect((await coordinator.syncOnce()).imported).toBe(1);
|
||||
expect((await coordinator.syncOnce()).duplicateOrStale).toBe(1);
|
||||
expect(acknowledgeWatchEvents).toHaveBeenCalledWith([fixtures.measurementEnvelope.eventId]);
|
||||
await expect(coordinator.connectionState()).resolves.toMatchObject({paired: true, watchAppInstalled: true, reachable: false});
|
||||
});
|
||||
|
||||
test('does not acknowledge malformed envelopes', async () => {
|
||||
const acknowledgeWatchEvents = jest.fn();
|
||||
const transport = {
|
||||
startWatchSession: jest.fn(),
|
||||
watchConnectionState: jest.fn(async () => ({supported: false, paired: false, watchAppInstalled: false, reachable: false, activationState: 'notActivated'})),
|
||||
drainWatchEvents: jest.fn(async () => ['not-json']),
|
||||
acknowledgeWatchEvents,
|
||||
sendWatchPreferences: jest.fn(async () => false),
|
||||
sendWatchCommand: jest.fn(async () => false),
|
||||
transferWatchEvent: jest.fn(async () => false),
|
||||
} as WatchNativeTransport;
|
||||
expect((await new WatchSyncCoordinator(transport).syncOnce()).invalid).toBe(1);
|
||||
expect(acknowledgeWatchEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import {NativeModules} from 'react-native';
|
||||
|
||||
import type {
|
||||
WatchConnectionState,
|
||||
WatchPreferencesV1,
|
||||
WatchSyncEnvelopeV1,
|
||||
} from './watchTypes';
|
||||
import {watchRepository, type WatchImportResult} from './watchRepository';
|
||||
import {parseWatchEnvelope} from './watchValidation';
|
||||
|
||||
export interface WatchNativeTransport {
|
||||
startWatchSession(): void;
|
||||
watchConnectionState(): Promise<{
|
||||
supported: boolean;
|
||||
paired: boolean;
|
||||
watchAppInstalled: boolean;
|
||||
reachable: boolean;
|
||||
activationState: string;
|
||||
latestEventId?: string;
|
||||
latestEventAt?: string;
|
||||
}>;
|
||||
drainWatchEvents(): Promise<string[]>;
|
||||
acknowledgeWatchEvents(eventIds: string[]): void;
|
||||
sendWatchPreferences(json: string): Promise<boolean>;
|
||||
sendWatchCommand(json: string): Promise<boolean>;
|
||||
transferWatchEvent(json: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface WatchSyncBatchResult {
|
||||
imported: number;
|
||||
duplicateOrStale: number;
|
||||
invalid: number;
|
||||
events: readonly WatchImportResult[];
|
||||
}
|
||||
|
||||
type NativeWatchModule = {
|
||||
start?: () => void;
|
||||
getConnectionState?: () => Promise<Awaited<ReturnType<WatchNativeTransport['watchConnectionState']>>>;
|
||||
drainEvents?: () => Promise<string[]>;
|
||||
acknowledgeEvents?: (ids: string[]) => void;
|
||||
sendPreferences?: (json: string) => Promise<boolean>;
|
||||
sendCommand?: (json: string) => Promise<boolean>;
|
||||
transferEvent?: (json: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const nativeModule = NativeModules.HaptikaWatchConnectivity as NativeWatchModule | undefined;
|
||||
const nativeTransport: WatchNativeTransport = {
|
||||
startWatchSession: () => nativeModule?.start?.(),
|
||||
watchConnectionState: async () => nativeModule?.getConnectionState?.() ?? {
|
||||
supported: false, paired: false, watchAppInstalled: false, reachable: false, activationState: 'notActivated',
|
||||
},
|
||||
drainWatchEvents: async () => nativeModule?.drainEvents?.() ?? [],
|
||||
acknowledgeWatchEvents: ids => nativeModule?.acknowledgeEvents?.(ids),
|
||||
sendWatchPreferences: async json => nativeModule?.sendPreferences?.(json) ?? false,
|
||||
sendWatchCommand: async json => nativeModule?.sendCommand?.(json) ?? false,
|
||||
transferWatchEvent: async json => nativeModule?.transferEvent?.(json) ?? false,
|
||||
};
|
||||
|
||||
function parseJSONEnvelope(json: string): WatchSyncEnvelopeV1 | null {
|
||||
try {
|
||||
return parseWatchEnvelope(JSON.parse(json));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class WatchSyncCoordinator {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private readonly transport: WatchNativeTransport = nativeTransport) {}
|
||||
|
||||
start(onImported?: () => void, pollMilliseconds = 1_500): void {
|
||||
this.transport.startWatchSession?.();
|
||||
if (this.timer !== null) return;
|
||||
const poll = (): void => {
|
||||
this.syncOnce().then(batch => {
|
||||
if (batch.imported > 0) onImported?.();
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
poll();
|
||||
this.timer = setInterval(poll, pollMilliseconds);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer !== null) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
async connectionState(): Promise<WatchConnectionState> {
|
||||
const state = await this.transport.watchConnectionState?.() ?? {
|
||||
supported: false, paired: false, watchAppInstalled: false, reachable: false, activationState: 'notActivated',
|
||||
};
|
||||
const activationState = state.activationState === 'activated' || state.activationState === 'inactive'
|
||||
? state.activationState
|
||||
: 'notActivated';
|
||||
return {
|
||||
supported: state.supported,
|
||||
paired: state.paired,
|
||||
watchAppInstalled: state.watchAppInstalled,
|
||||
reachable: state.reachable,
|
||||
activationState,
|
||||
latestEventId: state.latestEventId ?? null,
|
||||
latestEventAt: state.latestEventAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async syncOnce(): Promise<WatchSyncBatchResult> {
|
||||
const rawEvents = await this.transport.drainWatchEvents?.() ?? [];
|
||||
const results: WatchImportResult[] = [];
|
||||
const acknowledgements: string[] = [];
|
||||
for (const raw of rawEvents) {
|
||||
const envelope = parseJSONEnvelope(raw);
|
||||
if (envelope === null) {
|
||||
results.push({eventId: null, status: 'invalid'});
|
||||
continue;
|
||||
}
|
||||
const result = await watchRepository.importEnvelope(envelope);
|
||||
results.push(result);
|
||||
if (result.eventId !== null && result.status !== 'invalid') acknowledgements.push(result.eventId);
|
||||
}
|
||||
if (acknowledgements.length > 0) {
|
||||
this.transport.acknowledgeWatchEvents?.([...new Set(acknowledgements)]);
|
||||
}
|
||||
return {
|
||||
imported: results.filter(item => item.status === 'applied').length,
|
||||
duplicateOrStale: results.filter(item => item.status === 'duplicate' || item.status === 'stale').length,
|
||||
invalid: results.filter(item => item.status === 'invalid').length,
|
||||
events: results,
|
||||
};
|
||||
}
|
||||
|
||||
async updatePreferences(preferences: WatchPreferencesV1): Promise<boolean> {
|
||||
const envelope: WatchSyncEnvelopeV1<WatchPreferencesV1> = {
|
||||
schemaVersion: 1,
|
||||
eventId: `iphone-preferences-${Date.now()}`,
|
||||
deviceId: 'iphone',
|
||||
sequence: 0,
|
||||
sentAt: new Date().toISOString(),
|
||||
kind: 'preferences',
|
||||
payload: preferences,
|
||||
};
|
||||
return this.transport.sendWatchPreferences?.(JSON.stringify(envelope)) ?? false;
|
||||
}
|
||||
|
||||
async sendCommand(payload: Record<string, unknown>): Promise<boolean> {
|
||||
return this.transport.sendWatchCommand?.(JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
eventId: `iphone-command-${Date.now()}`,
|
||||
deviceId: 'iphone',
|
||||
sequence: 0,
|
||||
sentAt: new Date().toISOString(),
|
||||
kind: 'preferences',
|
||||
payload,
|
||||
})) ?? false;
|
||||
}
|
||||
|
||||
async transferEvent(envelope: WatchSyncEnvelopeV1): Promise<boolean> {
|
||||
return this.transport.transferWatchEvent?.(JSON.stringify(envelope)) ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
export const watchSyncCoordinator = new WatchSyncCoordinator();
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {FeelingScore} from '../ppg/storage/storageTypes';
|
||||
|
||||
export type MeasurementSource = 'iphoneCameraPPG' | 'appleWatchOptical';
|
||||
|
||||
export interface WatchDeviceIdentityV1 {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string | null;
|
||||
systemVersion: string | null;
|
||||
}
|
||||
|
||||
export interface WatchHeartRateSampleV1 {
|
||||
/** Seconds since `startedAt`; deliberately not presented as beat-to-beat data. */
|
||||
offsetSeconds: number;
|
||||
bpm: number;
|
||||
}
|
||||
|
||||
export interface WatchMeasurementContextV1 {
|
||||
atRest: boolean;
|
||||
posture: 'seated' | 'lying' | 'standing' | null;
|
||||
recentExercise: boolean | null;
|
||||
recentCaffeine: boolean | null;
|
||||
recentMeal: boolean | null;
|
||||
}
|
||||
|
||||
export interface WatchMeasurementFeelingV1 {
|
||||
score: FeelingScore;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export interface WatchMeasurementSummaryV1 {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
source: 'appleWatchOptical';
|
||||
device: WatchDeviceIdentityV1;
|
||||
currentBpm: number | null;
|
||||
averageBpm: number | null;
|
||||
minimumBpm: number | null;
|
||||
maximumBpm: number | null;
|
||||
samples: readonly WatchHeartRateSampleV1[];
|
||||
coverage: number;
|
||||
quality: number;
|
||||
valid: boolean;
|
||||
invalidReason: string | null;
|
||||
context: WatchMeasurementContextV1;
|
||||
feeling: WatchMeasurementFeelingV1 | null;
|
||||
}
|
||||
|
||||
export type WatchRegulationSessionState =
|
||||
| 'inProgress'
|
||||
| 'awaitingAfter'
|
||||
| 'completed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface WatchRegulationSessionV1 {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
protocolId: string;
|
||||
beforeMeasurementId: string | null;
|
||||
afterMeasurementId: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
durationSeconds: number;
|
||||
state: WatchRegulationSessionState;
|
||||
workoutUUID: string | null;
|
||||
feelingBefore: FeelingScore | null;
|
||||
feelingAfter: FeelingScore | null;
|
||||
}
|
||||
|
||||
export type WatchSyncKind =
|
||||
| 'liveHeartRate'
|
||||
| 'measurementUpsert'
|
||||
| 'regulationSessionUpsert'
|
||||
| 'feelingUpsert'
|
||||
| 'tombstone'
|
||||
| 'preferences'
|
||||
| 'ack';
|
||||
|
||||
export interface WatchSyncEnvelopeV1<T = unknown> {
|
||||
schemaVersion: 1;
|
||||
eventId: string;
|
||||
deviceId: string;
|
||||
sequence: number;
|
||||
sentAt: string;
|
||||
kind: WatchSyncKind;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface WatchTombstoneV1 {
|
||||
schemaVersion: 1;
|
||||
entityId: string;
|
||||
entityKind: 'measurement' | 'regulationSession';
|
||||
deletedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WatchPreferencesV1 {
|
||||
schemaVersion: 1;
|
||||
saveMindAndBodyWorkouts: boolean;
|
||||
protocolCatalogVersion: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WatchConnectionState {
|
||||
supported: boolean;
|
||||
paired: boolean;
|
||||
watchAppInstalled: boolean;
|
||||
reachable: boolean;
|
||||
activationState: 'notActivated' | 'inactive' | 'activated';
|
||||
latestEventId: string | null;
|
||||
latestEventAt: string | null;
|
||||
}
|
||||
|
||||
export interface WatchMeasurementComparisonV1 {
|
||||
beforeMeasurementId: string;
|
||||
afterMeasurementId: string;
|
||||
bpm: {before: number; after: number; delta: number};
|
||||
quality: {before: number; after: number; delta: number};
|
||||
feeling: {before: number; after: number; delta: number} | null;
|
||||
/** Explicitly absent because public Watch workout APIs do not expose reliable live PRV. */
|
||||
prvAvailable: false;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
WatchMeasurementSummaryV1,
|
||||
WatchRegulationSessionV1,
|
||||
WatchSyncEnvelopeV1,
|
||||
WatchTombstoneV1,
|
||||
} from './watchTypes';
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
const record = (value: unknown): value is RecordValue =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const date = (value: unknown): value is string =>
|
||||
typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
const nonEmpty = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
const nullableString = (value: unknown): value is string | null =>
|
||||
value === null || typeof value === 'string';
|
||||
const validBpm = (value: unknown): value is number =>
|
||||
finite(value) && value >= 35 && value <= 220;
|
||||
|
||||
export function parseWatchMeasurement(
|
||||
value: unknown,
|
||||
): WatchMeasurementSummaryV1 | null {
|
||||
if (!record(value) || !record(value.device) || !record(value.context)) return null;
|
||||
const samples = value.samples;
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
!nonEmpty(value.id) ||
|
||||
!date(value.updatedAt) ||
|
||||
!date(value.startedAt) ||
|
||||
!date(value.endedAt) ||
|
||||
value.source !== 'appleWatchOptical' ||
|
||||
!nonEmpty(value.device.id) ||
|
||||
!nonEmpty(value.device.name) ||
|
||||
!nullableString(value.device.model) ||
|
||||
!nullableString(value.device.systemVersion) ||
|
||||
!(value.currentBpm === null || validBpm(value.currentBpm)) ||
|
||||
!(value.averageBpm === null || validBpm(value.averageBpm)) ||
|
||||
!(value.minimumBpm === null || validBpm(value.minimumBpm)) ||
|
||||
!(value.maximumBpm === null || validBpm(value.maximumBpm)) ||
|
||||
!Array.isArray(samples) ||
|
||||
!samples.every(sample =>
|
||||
record(sample) && finite(sample.offsetSeconds) && sample.offsetSeconds >= 0 && validBpm(sample.bpm),
|
||||
) ||
|
||||
!finite(value.coverage) || value.coverage < 0 || value.coverage > 1 ||
|
||||
!finite(value.quality) || value.quality < 0 || value.quality > 1 ||
|
||||
typeof value.valid !== 'boolean' ||
|
||||
!nullableString(value.invalidReason) ||
|
||||
typeof value.context.atRest !== 'boolean' ||
|
||||
![null, 'seated', 'lying', 'standing'].includes(value.context.posture as never) ||
|
||||
!(value.context.recentExercise === null || typeof value.context.recentExercise === 'boolean') ||
|
||||
!(value.context.recentCaffeine === null || typeof value.context.recentCaffeine === 'boolean') ||
|
||||
!(value.context.recentMeal === null || typeof value.context.recentMeal === 'boolean')
|
||||
) return null;
|
||||
if (Date.parse(value.endedAt) < Date.parse(value.startedAt)) return null;
|
||||
if (finite(value.minimumBpm) && finite(value.maximumBpm) && finite(value.averageBpm) &&
|
||||
(value.minimumBpm > value.maximumBpm || value.averageBpm < value.minimumBpm || value.averageBpm > value.maximumBpm)) return null;
|
||||
let feeling: WatchMeasurementSummaryV1['feeling'] = null;
|
||||
if (value.feeling !== null) {
|
||||
if (!record(value.feeling) || ![1, 2, 3, 4, 5].includes(Number(value.feeling.score)) || !date(value.feeling.recordedAt)) return null;
|
||||
feeling = value.feeling as unknown as WatchMeasurementSummaryV1['feeling'];
|
||||
}
|
||||
return {...value, feeling} as unknown as WatchMeasurementSummaryV1;
|
||||
}
|
||||
|
||||
export function parseWatchRegulationSession(
|
||||
value: unknown,
|
||||
): WatchRegulationSessionV1 | null {
|
||||
if (!record(value)) return null;
|
||||
if (
|
||||
value.schemaVersion !== 1 || !nonEmpty(value.id) || !date(value.updatedAt) ||
|
||||
!nonEmpty(value.protocolId) || !(value.beforeMeasurementId === null || nonEmpty(value.beforeMeasurementId)) ||
|
||||
!(value.afterMeasurementId === null || nonEmpty(value.afterMeasurementId)) || !date(value.startedAt) ||
|
||||
!(value.endedAt === null || date(value.endedAt)) || !finite(value.durationSeconds) || value.durationSeconds < 0 ||
|
||||
!['inProgress', 'awaitingAfter', 'completed', 'cancelled'].includes(String(value.state)) ||
|
||||
!(value.workoutUUID === null || nonEmpty(value.workoutUUID)) ||
|
||||
!(value.feelingBefore === null || [1, 2, 3, 4, 5].includes(Number(value.feelingBefore))) ||
|
||||
!(value.feelingAfter === null || [1, 2, 3, 4, 5].includes(Number(value.feelingAfter)))
|
||||
) return null;
|
||||
return value as unknown as WatchRegulationSessionV1;
|
||||
}
|
||||
|
||||
export function parseWatchTombstone(value: unknown): WatchTombstoneV1 | null {
|
||||
if (!record(value) || value.schemaVersion !== 1 || !nonEmpty(value.entityId) ||
|
||||
!['measurement', 'regulationSession'].includes(String(value.entityKind)) ||
|
||||
!date(value.deletedAt) || !date(value.updatedAt)) return null;
|
||||
return value as unknown as WatchTombstoneV1;
|
||||
}
|
||||
|
||||
export function parseWatchEnvelope(value: unknown): WatchSyncEnvelopeV1 | null {
|
||||
if (!record(value) || value.schemaVersion !== 1 || !nonEmpty(value.eventId) ||
|
||||
!nonEmpty(value.deviceId) || !finite(value.sequence) || value.sequence < 0 || !date(value.sentAt) ||
|
||||
!['liveHeartRate', 'measurementUpsert', 'regulationSessionUpsert', 'feelingUpsert', 'tombstone', 'preferences', 'ack'].includes(String(value.kind)) ||
|
||||
!('payload' in value)) return null;
|
||||
return value as unknown as WatchSyncEnvelopeV1;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export const colors = {
|
||||
background: '#090A0D',
|
||||
surface: '#14161B',
|
||||
surfaceElevated: '#1B1E24',
|
||||
border: '#2A2E36',
|
||||
text: '#F5F4EF',
|
||||
muted: '#A7ABB4',
|
||||
accent: '#FF6B52',
|
||||
accentSoft: '#3D211E',
|
||||
good: '#58D6A4',
|
||||
medium: '#F4B860',
|
||||
poor: '#FF7867',
|
||||
blue: '#79A9FF',
|
||||
white: '#FFFFFF',
|
||||
} as const;
|
||||
|
||||
export const spacing = {
|
||||
xs: 6,
|
||||
sm: 10,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
} as const;
|
||||
|
||||
export const radii = {
|
||||
sm: 10,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
pill: 999,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user