feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
@@ -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},
});