@@ -132,20 +202,53 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
}
const capture = timeline.capture
- const start = Date.parse(capture.started_at)
- const end = Date.parse(capture.ended_at)
- const duration = Math.max(1, end - start)
- const bpmFloor = Math.max(0, calculated.minimum - 8)
- const bpmCeiling = calculated.maximum + 8
+ const sleepEvents = sleepEntry?.events ?? []
+ const sleepIntakes = sleepEntry?.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 + ((Date.parse(timestamp) - start) / duration) * (WIDTH - LEFT - RIGHT)
+ LEFT + timelinePosition(timestamp, start, end) * (WIDTH - LEFT - RIGHT)
const y = (bpm: number) =>
TOP + ((bpmCeiling - bpm) / bpmSpan) * (HEIGHT - TOP - BOTTOM)
- const points = calculated.display
+ const points = (calculated?.display ?? [])
.map((sample) => String(x(sample.observed_at)) + ',' + String(y(sample.bpm)))
.join(' ')
- const visibleEvents = timeline.events.slice(0, 24)
+ 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'}
+ {!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.'}
+
+ {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.'}
+
{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.'}
- {french ? 'Rythme cardiaque' : 'Heart rate'}
+ {calculated &&
}
+