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 = { 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 = { 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 = { 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(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
{french ? 'Données cardio momentanément indisponibles.' : 'Heart-rate data is temporarily unavailable.'}
} if (!timeline) { return
{french ? 'Chargement de la courbe cardio…' : 'Loading heart-rate timeline…'}
} if ((!timeline.available || !timeline.capture || !calculated) && !sleepEntry) { return

{french ? 'Rythme cardiaque' : 'Heart rate'}

{french ? 'Aucune capture cardio synchronisée pour cette période.' : 'No synchronized heart-rate capture for this period.'}

} 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

{french ? 'DONNÉES MESURÉES' : 'MEASURED DATA'}

{french ? 'Rythme cardiaque' : 'Heart rate'}

{calculated &&
Min
{calculated.minimum} BPM
{french ? 'Moy.' : 'Avg.'}
{calculated.average.toFixed(1)} BPM
Max
{calculated.maximum} BPM
RR
{calculated.rrCount}
}
{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 {`${title} · ${clock(range.start)}–${clock(range.end)}`} })} {(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 {title} })} {hourlyTicks.map((tick) => { const tickX = LEFT + tick.position * (WIDTH - LEFT - RIGHT) return {clock(tick.timestamp)} })} {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 })} {!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 })} {(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 {title} })} {(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 {label + ': ' + variation.observed_bpm.toFixed(1) + ' BPM / ' + variation.reference_bpm.toFixed(1) + ' BPM'} })} {points.length > 0 && } {visibleEvents.map((event, index) => { const eventX = x(event.at) return {eventLabel(event, french)} })} {(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 {title} 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} })} {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 {title} M{title} })} {clock(start)} {clock(end)} {calculated && <> {calculated.maximum} {calculated.minimum} } {capture && calculated && capture.samples.length > calculated.display.length &&
{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.'}
}
{!calculated &&

{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.'}

} {sleep.some((range) => range.estimated) &&

{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.'}

} {calculated &&

{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.'}

} {visibleSleepIntakes.length > 0 &&
    {visibleSleepIntakes.map((intake) =>
  • {' · '}{doseLabel(intake)}
  • )}
} {timeline.guidance &&

{french ? 'Phases guidées' : 'Guided phases'}

    {timeline.guidance.phases.map((phase) =>
  1. {phaseLabel(phase.kind, french)} {phase.target ? String(phase.target.minimum_bpm) + '–' + String(phase.target.maximum_bpm) + ' BPM' : '—'} {instructionLabel(phase.final_instruction, french)}
  2. )}
} {timeline.calibration &&

{french ? 'Calibration utilisée / observée' : 'Observed calibration'}

{french ? 'Pic observé' : 'Observed peak'} : {timeline.calibration.observed_peak_bpm} BPM

{timeline.calibration.recovery.length > 0 &&

{timeline.calibration.recovery.map((point) => '+' + String(Math.round(point.offset_seconds / 60)) + ' min: ' + String(point.bpm) + ' BPM').join(' · ')}

}
} {timeline.events.length > 0 &&
{french ? 'Marqueurs factuels' : 'Factual markers'} ({timeline.events.length})
    {timeline.events.map((event, index) =>
  • {' · '}{eventLabel(event, french)}
  • )}
}
}