trainlog/web/src/components/HeartRateTimelinePanel.tsx

449 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react'
import {
fetchHeartRateTimeline,
type HeartRateSample,
type HeartRateTimeline,
type HeartRateTimelineEvent,
} from '../api/heartRate'
import type { MedicationIntake, SleepEntry, SleepEvent } from '../api/sleepDiary'
import {
hourlyTimelineTicks,
timelineInterval,
timelinePosition,
} from '../routes/sleepTimelineGeometry'
import { projectSleepTimeline } from '../routes/sleepVisualProjection'
import { heartRateVariations } from './heartRateVariation'
const WIDTH = 1000
const HEIGHT = 280
const LEFT = 48
const RIGHT = 18
const TOP = 22
const BOTTOM = 38
const DISPLAY_BUCKETS = 1000
function project(samples: HeartRateSample[]): HeartRateSample[] {
if (samples.length <= DISPLAY_BUCKETS * 2) return samples
const size = Math.ceil(samples.length / DISPLAY_BUCKETS)
const selected: HeartRateSample[] = []
for (let start = 0; start < samples.length; start += size) {
const bucket = samples.slice(start, Math.min(samples.length, start + size))
if (bucket.length === 0) continue
let minimum = bucket[0]
let maximum = bucket[0]
for (const sample of bucket) {
if (sample.bpm < minimum.bpm) minimum = sample
if (sample.bpm > maximum.bpm) maximum = sample
}
if (minimum.observed_at <= maximum.observed_at) {
selected.push(minimum)
if (maximum !== minimum) selected.push(maximum)
} else {
selected.push(maximum)
if (maximum !== minimum) selected.push(minimum)
}
}
return selected
}
function eventLabel(event: HeartRateTimelineEvent, french: boolean): string {
if (event.label) return event.label
const labels: Record<string, [string, string]> = {
session: ['Séance', 'Session'],
bed_time: ['Couché', 'Bedtime'],
final_get_up: ['Levé', 'Final get-up'],
night_get_up: ['Réveil', 'Awakening'],
medication: ['Médicament', 'Medication'],
sleep: ['Sommeil déclaré', 'Declared sleep'],
nap: ['Sieste', 'Nap'],
long_awake: ['Éveil long', 'Long awakening'],
half_sleep: ['Demi-sommeil', 'Half-sleep'],
daytime_sleepiness: ['Somnolence', 'Sleepiness'],
}
return labels[event.type]?.[french ? 0 : 1] ?? event.type
}
function instructionLabel(value: string, french: boolean): string {
const labels: Record<string, [string, string]> = {
accelerate: ['Accélère', 'Speed up'],
maintain: ['Maintiens', 'Maintain'],
slow_down: ['Ralentis', 'Slow down'],
suspended: ['Signal indisponible', 'Signal unavailable'],
}
return labels[value]?.[french ? 0 : 1] ?? value
}
function phaseLabel(value: string, french: boolean): string {
const labels: Record<string, [string, string]> = {
warmup: ['Échauffement', 'Warm-up'],
work: ['Travail', 'Work'],
recovery: ['Récupération', 'Recovery'],
cooldown: ['Retour au calme', 'Cool-down'],
}
return labels[value]?.[french ? 0 : 1] ?? value
}
function clock(timestamp: string | number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
}
function doseLabel(intake: MedicationIntake): string {
if (intake.dose_value === null) return intake.medication_name
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : ' ×1'
return `${intake.medication_name} · ${intake.dose_value} ${intake.dose_unit ?? ''}${quantity}`.trim()
}
function markerTitle(event: SleepEvent, french: boolean): string {
const timelineEvent: HeartRateTimelineEvent = {
type: event.type,
at: event.start_at,
end_at: event.end_at,
label: null,
entry_id: null,
exercise_id: null,
}
return `${eventLabel(timelineEvent, french)} · ${clock(event.start_at)}`
}
export function HeartRateTimelinePanel({
contextId,
sleepEntry,
}: {
contextId: string
sleepEntry?: SleepEntry
}) {
const french = !document.documentElement.lang.toLowerCase().startsWith('en')
const [timeline, setTimeline] = useState<HeartRateTimeline | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
const controller = new AbortController()
setTimeline(null)
setFailed(false)
fetchHeartRateTimeline(contextId, controller.signal)
.then((value) => {
if (!controller.signal.aborted) setTimeline(value)
})
.catch(() => {
if (!controller.signal.aborted) setFailed(true)
})
return () => controller.abort()
}, [contextId])
const calculated = useMemo(() => {
const samples = timeline?.capture?.samples ?? []
if (samples.length === 0) return null
let minimum = samples[0].bpm
let maximum = samples[0].bpm
let total = 0
let rrCount = 0
for (const sample of samples) {
if (sample.bpm < minimum) minimum = sample.bpm
if (sample.bpm > maximum) maximum = sample.bpm
total += sample.bpm
rrCount += sample.rr_1024.length
}
const average = total / samples.length
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
}, [timeline])
const sleepProjection = sleepEntry ? projectSleepTimeline(sleepEntry) : null
const sleep = sleepProjection?.sleep ?? []
if (failed) {
return <section className="heart-rate-panel" role="alert">
{french ? 'Données cardio momentanément indisponibles.' : 'Heart-rate data is temporarily unavailable.'}
</section>
}
if (!timeline) {
return <section className="heart-rate-panel" aria-live="polite">
{french ? 'Chargement de la courbe cardio…' : 'Loading heart-rate timeline…'}
</section>
}
if ((!timeline.available || !timeline.capture || !calculated) && !sleepEntry) {
return <section className="heart-rate-panel">
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
<p className="analysis-empty">
{french ? 'Aucune capture cardio synchronisée pour cette période.' : 'No synchronized heart-rate capture for this period.'}
</p>
</section>
}
const capture = timeline.capture
const sleepEvents = sleepEntry?.events ?? []
const sleepIntakes = sleepProjection?.intakes ?? []
const bedTime = sleepEvents.find((event) => event.type === 'bed_time')
const finalGetUp = sleepEvents.find((event) => event.type === 'final_get_up')
const finalGetUpTime = finalGetUp ? Date.parse(finalGetUp.start_at) : null
const visibleSleepIntakes = sleepIntakes.filter((intake) => {
const intakeTime = Date.parse(intake.taken_at)
return Number.isFinite(intakeTime) &&
(finalGetUpTime === null || !Number.isFinite(finalGetUpTime) || intakeTime <= finalGetUpTime)
})
const boundaryTimes = bedTime && finalGetUp
? [
Date.parse(bedTime.start_at),
Date.parse(finalGetUp.start_at),
...visibleSleepIntakes.map((intake) => Date.parse(intake.taken_at)),
...(capture ? [Date.parse(capture.started_at), Date.parse(capture.ended_at)] : []),
]
: [
...(capture ? [Date.parse(capture.started_at), Date.parse(capture.ended_at)] : []),
...sleepEvents.flatMap((event) => [
Date.parse(event.start_at),
...(event.end_at ? [Date.parse(event.end_at)] : []),
]),
...visibleSleepIntakes.map((intake) => Date.parse(intake.taken_at)),
]
// CONTRACT: a complete night is bounded by its factual bedtime/final get-up,
// while pre-bed intakes and the measured capture may widen those bounds.
// Daytime diary facts must not compress the nocturnal HR comparison.
const factualTimes = boundaryTimes.filter(Number.isFinite)
const start = factualTimes.length > 0 ? Math.min(...factualTimes) : 0
const end = factualTimes.length > 0 ? Math.max(...factualTimes) : start + 1
const bpmFloor = Math.max(0, (calculated?.minimum ?? 60) - 8)
const bpmCeiling = (calculated?.maximum ?? 100) + 8
const bpmSpan = Math.max(1, bpmCeiling - bpmFloor)
const x = (timestamp: string) =>
LEFT + timelinePosition(timestamp, start, end) * (WIDTH - LEFT - RIGHT)
const y = (bpm: number) =>
TOP + ((bpmCeiling - bpm) / bpmSpan) * (HEIGHT - TOP - BOTTOM)
const points = (calculated?.display ?? [])
.map((sample) => String(x(sample.observed_at)) + ',' + String(y(sample.bpm)))
.join(' ')
const visibleEvents = sleepEntry ? [] : timeline.events.slice(0, 24)
const hourlyTicks = hourlyTimelineTicks(start, end)
const visibleSleepEvents = sleepEvents.filter((event) => {
const eventTime = Date.parse(event.start_at)
return Number.isFinite(eventTime) && eventTime >= start && eventTime <= end
})
return <section className="heart-rate-panel" data-testid="heart-rate-timeline">
<div className="heart-rate-heading">
<div>
<p className="eyebrow">{french ? 'DONNÉES MESURÉES' : 'MEASURED DATA'}</p>
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
</div>
{calculated && <dl className="heart-rate-facts">
<div><dt>Min</dt><dd>{calculated.minimum} BPM</dd></div>
<div><dt>{french ? 'Moy.' : 'Avg.'}</dt><dd>{calculated.average.toFixed(1)} BPM</dd></div>
<div><dt>Max</dt><dd>{calculated.maximum} BPM</dd></div>
<div><dt>RR</dt><dd>{calculated.rrCount}</dd></div>
</dl>}
</div>
<figure className="heart-rate-chart">
<svg viewBox={'0 0 ' + WIDTH + ' ' + HEIGHT} role="img"
aria-label={french ? 'Courbe du rythme cardiaque mesuré' : 'Measured heart-rate curve'}>
{sleep.map((range, index) => {
const interval = timelineInterval(range.start, range.end, start, end)
const rangeX = LEFT + interval.left * (WIDTH - LEFT - RIGHT)
const width = Math.max(1, interval.width * (WIDTH - LEFT - RIGHT))
const title = range.estimated
? (french ? 'Sommeil estimé (repère visuel, non médical)' : 'Estimated sleep (visual guide, not medical)')
: (french ? 'Sommeil déclaré' : 'Declared sleep')
return <rect key={`sleep-${index}`}
className={`heart-rate-sleep-band${range.estimated ? ' heart-rate-sleep-estimated' : ''}`}
data-testid={range.estimated ? 'heart-rate-sleep-estimated' : 'heart-rate-sleep-factual'}
tabIndex={0} role="img"
aria-label={`${title} · ${clock(range.start)}–${clock(range.end)}`}
x={rangeX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM}>
<title>{`${title} · ${clock(range.start)}–${clock(range.end)}`}</title>
</rect>
})}
{(sleepProjection?.awake ?? []).filter((range) =>
range.start >= start && range.start <= end && range.event).map((range) => {
const event = range.event as SleepEvent
const interval = timelineInterval(range.start, range.end, start, end)
const title = markerTitle(event, french)
return <rect key={event.event_id} className="heart-rate-awake-band"
data-testid="heart-rate-awake-band"
tabIndex={0} role="img" aria-label={title}
x={LEFT + interval.left * (WIDTH - LEFT - RIGHT)} y={TOP}
width={Math.max(1, interval.width * (WIDTH - LEFT - RIGHT))}
height={HEIGHT - TOP - BOTTOM}>
<title>{title}</title>
</rect>
})}
{hourlyTicks.map((tick) => {
const tickX = LEFT + tick.position * (WIDTH - LEFT - RIGHT)
return <g key={tick.timestamp} data-testid="heart-rate-hour-tick">
<line className="heart-rate-hour-grid" x1={tickX} y1={TOP}
x2={tickX} y2={HEIGHT - BOTTOM} />
<text className="chart-axis-label heart-rate-hour-label" textAnchor="middle"
x={tickX} y={HEIGHT - 10}>{clock(tick.timestamp)}</text>
</g>
})}
{timeline.guidance?.phases.map((phase) => {
if (!phase.target) return null
const phaseX = x(phase.started_at)
const phaseWidth = Math.max(1, x(phase.ended_at) - phaseX)
const targetTop = y(phase.target.maximum_bpm)
const targetHeight = Math.max(1, y(phase.target.minimum_bpm) - targetTop)
return <rect key={phase.phase_id} className="heart-rate-target"
x={phaseX} y={targetTop} width={phaseWidth} height={targetHeight} />
})}
{!sleepEntry && timeline.events.filter((event) => event.end_at).map((event, index) => {
const eventX = x(event.at)
const width = Math.max(1, x(event.end_at as string) - eventX)
return <rect key={'interval-' + index} className="heart-rate-event-band"
x={eventX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM} />
})}
{(sleepProjection?.otherIntervals ?? []).filter((range) =>
range.start >= start && range.start <= end && range.event).map((range) => {
const event = range.event as SleepEvent
const interval = timelineInterval(range.start, range.end, start, end)
const title = markerTitle(event, french)
return <rect key={`sleep-interval-${event.event_id}`}
className="heart-rate-event-band"
data-testid={`heart-rate-sleep-interval-${event.type}`}
tabIndex={0} role="img" aria-label={title}
x={LEFT + interval.left * (WIDTH - LEFT - RIGHT)} y={TOP}
width={Math.max(1, interval.width * (WIDTH - LEFT - RIGHT))}
height={HEIGHT - TOP - BOTTOM}>
<title>{title}</title>
</rect>
})}
{(calculated?.variations ?? []).map((variation, index) => {
const variationX = x(variation.started_at)
const width = Math.max(2, x(variation.ended_at) - variationX)
const label = variation.direction === 'rise'
? (french ? 'Hausse notable' : 'Notable rise')
: (french ? 'Baisse notable' : 'Notable fall')
return <rect key={'variation-' + index}
className={'heart-rate-variation heart-rate-variation-' + variation.direction}
x={variationX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM}>
<title>{label + ': ' + variation.observed_bpm.toFixed(1) + ' BPM / ' + variation.reference_bpm.toFixed(1) + ' BPM'}</title>
</rect>
})}
<line className="chart-axis" x1={LEFT} y1={HEIGHT - BOTTOM}
x2={WIDTH - RIGHT} y2={HEIGHT - BOTTOM} />
{points.length > 0 && <polyline className="heart-rate-line" points={points} fill="none" />}
{visibleEvents.map((event, index) => {
const eventX = x(event.at)
return <g key={'marker-' + index}>
<line className="heart-rate-marker" x1={eventX} y1={TOP}
x2={eventX} y2={HEIGHT - BOTTOM} />
<text className="heart-rate-marker-label" x={eventX + 4}
y={TOP + 12 + (index % 3) * 13}>{eventLabel(event, french)}</text>
</g>
})}
{(sleepProjection?.pointEvents ?? visibleSleepEvents)
.filter((event) => event.type !== 'sleep' && Date.parse(event.start_at) >= start &&
Date.parse(event.start_at) <= end)
.map((event, index) => {
const eventX = x(event.start_at)
const title = markerTitle(event, french)
return <g key={event.event_id} data-testid={`heart-rate-sleep-event-${event.type}`}
tabIndex={0} role="img" aria-label={title}>
<line className={`heart-rate-sleep-marker heart-rate-sleep-marker-${event.type}`}
x1={eventX} y1={TOP} x2={eventX} y2={HEIGHT - BOTTOM}>
<title>{title}</title>
</line>
<text className="heart-rate-marker-label" textAnchor={eventX > WIDTH - 150 ? 'end' : 'start'}
x={eventX + (eventX > WIDTH - 150 ? -4 : 4)} y={TOP + 12 + (index % 3) * 13}>
{eventLabel({ type: event.type } as HeartRateTimelineEvent, french)}
<title>{title}</title>
</text>
</g>
})}
{visibleSleepIntakes.map((intake, index) => {
const intakeX = x(intake.taken_at)
const title = `${intake.medication_name} · ${clock(intake.taken_at)} · ${
intake.dose_value === null ? (french ? 'dose non renseignée' : 'dose not provided') :
`${intake.dose_value} ${intake.dose_unit ?? ''} ×${intake.quantity}`
}`
return <g key={intake.intake_id} data-testid="heart-rate-medication-marker"
tabIndex={0} role="img" aria-label={title}>
<circle className="heart-rate-medication-marker" cx={intakeX}
cy={HEIGHT - BOTTOM - 12 - (index % 2) * 14} r={6}>
<title>{title}</title>
</circle>
<text className="heart-rate-medication-label" textAnchor="middle" x={intakeX}
y={HEIGHT - BOTTOM - 9 - (index % 2) * 14}>M<title>{title}</title></text>
</g>
})}
<text className="chart-axis-label" x={LEFT} y={HEIGHT - 10}>
{clock(start)}
</text>
<text className="chart-axis-label" textAnchor="end" x={WIDTH - RIGHT} y={HEIGHT - 10}>
{clock(end)}
</text>
{calculated && <>
<text className="chart-axis-label" x={4} y={y(calculated.maximum) + 4}>
{calculated.maximum}
</text>
<text className="chart-axis-label" x={4} y={y(calculated.minimum) + 4}>
{calculated.minimum}
</text>
</>}
</svg>
{capture && calculated && capture.samples.length > calculated.display.length &&
<figcaption>
{french
? 'Projection graphique bornée conservant les minima/maxima ; statistiques calculées sur toutes les mesures.'
: 'Bounded chart projection preserves minima/maxima; statistics use every measurement.'}
</figcaption>}
</figure>
{!calculated && <p className="analysis-empty">
{french
? 'Aucune capture cardio synchronisée ; les repères Sleep restent affichés sans inventer de mesures.'
: 'No synchronized heart-rate capture; Sleep markers remain visible without invented measurements.'}
</p>}
{sleep.some((range) => range.estimated) && <p className="heart-rate-estimation-note">
{french
? 'Sommeil estimé : mise au lit +45 min → lever final −10 min. Repère visuel non médical ; les données Sleep ne sont pas modifiées.'
: 'Estimated sleep: bedtime +45 min → final get-up −10 min. Non-medical visual guide; Sleep data is unchanged.'}
</p>}
{calculated && <p className="heart-rate-variation-legend">
{french
? 'Rouge : hausse notable · pêche : baisse notable. Variations relatives mesurées, sans inférence sur le sommeil ni leur cause.'
: 'Red: notable rise · peach: notable fall. Relative measured variations, with no sleep-state or cause inference.'}
</p>}
{visibleSleepIntakes.length > 0 && <ul className="heart-rate-overlay-list" aria-label={french ? 'Prises médicamenteuses' : 'Medication intakes'}>
{visibleSleepIntakes.map((intake) => <li key={intake.intake_id}>
<time dateTime={intake.taken_at}>{clock(intake.taken_at)}</time>
{' · '}{doseLabel(intake)}
</li>)}
</ul>}
{timeline.guidance && <div className="heart-rate-phases">
<h4>{french ? 'Phases guidées' : 'Guided phases'}</h4>
<ol>
{timeline.guidance.phases.map((phase) => <li key={phase.phase_id}>
<strong>{phaseLabel(phase.kind, french)}</strong>
<span>{phase.target
? String(phase.target.minimum_bpm) + '–' + String(phase.target.maximum_bpm) + ' BPM'
: '—'}</span>
<small>{instructionLabel(phase.final_instruction, french)}</small>
</li>)}
</ol>
</div>}
{timeline.calibration && <div className="heart-rate-calibration">
<h4>{french ? 'Calibration utilisée / observée' : 'Observed calibration'}</h4>
<p>{french ? 'Pic observé' : 'Observed peak'} : <strong>
{timeline.calibration.observed_peak_bpm} BPM</strong></p>
{timeline.calibration.recovery.length > 0 && <p>
{timeline.calibration.recovery.map((point) =>
'+' + String(Math.round(point.offset_seconds / 60)) + ' min: ' +
String(point.bpm) + ' BPM').join(' · ')}
</p>}
</div>}
{timeline.events.length > 0 && <details className="heart-rate-events">
<summary>{french ? 'Marqueurs factuels' : 'Factual markers'} ({timeline.events.length})</summary>
<ul>{timeline.events.map((event, index) => <li key={String(index) + event.at}>
<time dateTime={event.at}>
{new Date(event.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
{' · '}{eventLabel(event, french)}
</li>)}</ul>
</details>}
</section>
}