feat(web): enrich sleep heart-rate timeline overlays

This commit is contained in:
fy59 2026-09-24 06:26:56 +02:00
parent 3a51211f01
commit 2ce458d286
17 changed files with 591 additions and 55 deletions

View file

@ -34,6 +34,14 @@ also refined.
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
through import, ACK, replay and a second Sleep-only generation with no
Android workout session.
- Enriched the selected Sleep night's heart-rate chart with declared sleep
ranges, explicit awakening ranges and point events, medication markers that
preserve unit dose and quantity, exact start/end bounds, and every
intervening hourly tick. When no declared sleep interval exists, the Web
presentation alone shows the conservative bedtime +45 minutes to final
get-up -10 minutes estimate, labels it as estimated/non-medical, and never
writes it to Sleep data. Nights without synchronized HR retain these factual
and estimated timeline overlays without inventing BPM samples.
## 0.1.5 — 2026-09-23

View file

@ -189,6 +189,14 @@ heart-rate detail. Its rise/fall overlays are descriptive relative measured
variation only (five-minute trailing reference excluding the current 60-second
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
are not sleep events, sleep stages, causes, readiness, or medical inference.
The detail shares one normalized time projection with declared sleep and
awakening intervals, exact Couché/Réveil/Levé markers, medication markers, and
full-hour ticks between exact visible bounds. Medication presentation retains
unit dose and quantity as separate facts. A missing declared sleep interval
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
data. The overlays remain available without an HR capture and do not fabricate
BPM.
Language is device-local presentation state. Android keeps it in dedicated
SharedPreferences; desktop keeps it in the XDG configuration file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View file

@ -13,7 +13,7 @@ endobj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
endobj
5 0 obj
<< /Length 17659 >>
<< /Length 17665 >>
stream
0 G 0 g
0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET
@ -53,8 +53,8 @@ stream
0 g BT /F1 4 Tf 728.0 552.0 Td (TRAITEMENT ET) Tj ET
0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) Tj ET
0.7 G 30 498 782 40 re S
0 g BT /F1 5 Tf 33.0 525.0 Td (20/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 515.0 Td (au 21/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 525.0 Td (23/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 515.0 Td (au 24/09/2026) Tj ET
0.9 G 112 498 m 112 538 l S
0.9 G 132.83333333333334 498 m 132.83333333333334 538 l S
0.9 G 153.66666666666666 498 m 153.66666666666666 538 l S
@ -92,8 +92,8 @@ stream
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 658.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 696.0 522.0 Td (-) Tj ET
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 37,5 mg ; 22:00 \227 ) Tj ET
0 g BT /F1 4 Tf 730.0 522.0 Td (venlafaxine \227 75 mg) Tj ET
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 75 mg ; 22:00 \227 ve) Tj ET
0 g BT /F1 4 Tf 730.0 522.0 Td (nlafaxine \227 37,5 mg) Tj ET
0 g BT /F1 4 Tf 730.0 513.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 504.0 Td () Tj ET
0.7 G 30 458 782 40 re S
@ -124,17 +124,17 @@ stream
0.9 G 570.3333333333333 458 m 570.3333333333333 498 l S
0.9 G 591.1666666666667 458 m 591.1666666666667 498 l S
0.9 G 612 458 m 612 498 l S
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
0.75 g 205.75 469.00 104.17 18.00 re f 0 G 205.75 469.00 104.17 18.00 re S
0 g BT /F1 5 Tf 207.8 475.0 Td (SOMMEIL) Tj ET
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
0 g BT /F1 8 Tf 336.0 473.0 Td (^) Tj ET
0 g BT /F1 8 Tf 559.9 473.0 Td (S) Tj ET
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
0 g BT /F1 7 Tf 620.0 482.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 482.0 Td (TB) Tj ET
0 g BT /F1 7 Tf 696.0 482.0 Td (Moy) Tj ET
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg | Nuit de co) Tj ET
0 g BT /F1 4 Tf 730.0 482.0 Td (ntr\364le g\351om\351trique.) Tj ET
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg \3272 | Nuit de) Tj ET
0 g BT /F1 4 Tf 730.0 482.0 Td ( contr\364le g\351om\351trique.) Tj ET
0 g BT /F1 4 Tf 730.0 473.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 464.0 Td () Tj ET
0.7 G 30 418 782 40 re S
@ -383,5 +383,5 @@ xref
trailer
<< /Size 6 /Root 1 0 R >>
startxref
18059
18065
%%EOF

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View file

@ -63,6 +63,11 @@ synchronized factual measurements and never connect directly to the sensor.
The heart-rate path remains independent from the frozen Bluetooth Trainlog
synchronization transport.
The 0.1.6 Web refinement now also layers factual Sleep intervals, awakenings,
medication intakes and complete hourly ticks on the selected night's HR chart.
Its explicitly labeled fallback estimate is presentation-only; release work
remains the next separate operational step.
```text
CURRENT_OPERATIONAL_CURSOR=0.1.6 post-review Web corrections validated and privately deployed; stable release work remains separate.
```

View file

@ -316,6 +316,13 @@ cover exact 18:00-to-18:00 timestamp geometry, active-night isolation, empty
night state, return navigation and rejection of an earlier date response that
finishes late. Localized dose/unit labels, same-name medications with different
usual doses and editable intake-dose prefill remain covered.
The heart-rate panel suite additionally covers a complete 22:05-to-04:14 night,
declared and fallback-estimated sleep ranges, multiple close awakenings, final
get-up, pre-bed and close medication intakes with exact dose/unit/quantity,
every intervening hour tick, and a no-HR rendering that creates no measured
curve. Geometry tests prove exact factual bounds and the common normalized
projection used by chart samples, overlays, events, intakes, and ticks; narrow
viewports preserve that geometry in a horizontally scrollable chart.
`test_web_sleep_browser.py` exercises the embedded production bundle in real
Firefox against an isolated synthetic database, observes every mutation
without reload, verifies HTTP success and browser error collectors, measures

View file

@ -368,6 +368,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
"taken_at": "2026-09-19T22:30:00+02:00",
"dose_value": 75,
"dose_unit": "mg",
"quantity": 2,
"note": "",
"created_at": "2026-09-19T22:30:00+02:00",
}
@ -381,6 +382,36 @@ class BrowserSleepDiaryTest(unittest.TestCase):
By.CSS_SELECTOR, "[data-testid='sleep-agenda-event-bed_time']"
)
)
wait.until(
lambda current: current.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-sleep-factual']"
)
)
self.assertEqual(
1,
len(driver.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-medication-marker']"
)),
)
self.assertEqual(
1,
len(driver.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-sleep-event-final_get_up']"
)),
)
self.assertGreaterEqual(
len(driver.find_elements(
By.CSS_SELECTOR, "[data-testid='heart-rate-hour-tick']"
)),
6,
)
self.assertIn(
"venlafaxine · 75 mg ×2",
driver.find_element(By.CSS_SELECTOR, ".heart-rate-overlay-list").text,
)
self.assertFalse(
driver.find_elements(By.CSS_SELECTOR, ".heart-rate-line")
)
geometry = driver.execute_script(
"""
const axis=document.querySelector('.sleep-hour-axis').getBoundingClientRect();
@ -400,6 +431,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
return {
label:value.textContent,
center:tick.left + tick.width / 2,
left:value.style.left,
};
});
return {
@ -428,23 +460,27 @@ class BrowserSleepDiaryTest(unittest.TestCase):
self.assertAlmostEqual(
expected_x, geometry[key], delta=1.0, msg=key
)
for tick_index in (4, 9, 10, 11, 21, 22):
expected_x = geometry["left"] + geometry["width"] * tick_index / 24
self.assertAlmostEqual(
expected_x,
geometry["ticks"][tick_index]["center"],
delta=1.0,
)
agenda_tick_hours = (0, 6, 12, 18, 24)
self.assertEqual(
["18:00", "00:00", "06:00", "12:00", "18:00"],
[tick["label"] for tick in geometry["ticks"]],
)
self.assertEqual(
[f"{tick_hour / 24 * 100:g}%" for tick_hour in agenda_tick_hours],
[tick["left"] for tick in geometry["ticks"]],
)
self.assertTrue(
driver.save_screenshot(
str(EVIDENCE / "sleep-diary-alignment-firefox.png")
)
)
set_value("sleep-night-start", "2026-09-21")
set_value("sleep-night-start", "2025-01-01")
wait.until(
lambda current: "Aucune donnée pour cette nuit."
in current.page_source
lambda current: current.find_element(
By.CSS_SELECTOR, "[data-testid='sleep-night-start']"
).get_attribute("value")
== "2025-01-01"
)
self.assertFalse(
driver.find_elements(
@ -599,7 +635,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
)
)
== 1
== 7
)
EVIDENCE.mkdir(parents=True, exist_ok=True)
download_path = EVIDENCE / "trainlog-sleep-diary.pdf"

View file

@ -48,6 +48,8 @@ if web_assets_enabled
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/analysis.ts',
'../web/src/api/heartRate.ts',
'../web/src/api/heartRate.test.ts',
'../web/src/api/sleepDiary.ts',
'../web/src/api/analysis.test.ts',
'../web/src/analysis/messages.ts',
@ -64,6 +66,10 @@ if web_assets_enabled
'../web/src/components/DeleteConfirmationDialog.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/HeartRateTimelinePanel.tsx',
'../web/src/components/HeartRateTimelinePanel.test.tsx',
'../web/src/components/heartRateVariation.ts',
'../web/src/components/heartRateVariation.test.ts',
'../web/src/components/SyncControl.tsx',
'../web/src/components/SyncControl.test.tsx',
'../web/src/components/Tile.tsx',
@ -80,6 +86,8 @@ if web_assets_enabled
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/AnalysisPage.tsx',
'../web/src/routes/SleepDiaryWorkspace.tsx',
'../web/src/routes/sleepTimelineGeometry.ts',
'../web/src/routes/sleepTimelineGeometry.test.ts',
'../web/src/report/sleepDiaryPdf.ts',
'../web/src/report/sleepDiaryPdf.test.ts',
'../web/src/routes/AnalysisPage.test.tsx',

View file

@ -1,10 +1,45 @@
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { HeartRateTimelinePanel } from './HeartRateTimelinePanel'
import type { SleepEntry } from '../api/sleepDiary'
afterEach(() => vi.restoreAllMocks())
describe('HeartRateTimelinePanel', () => {
const sleepEntry = (withSleep = true): SleepEntry => ({
entry_id: 'sl_11111111-1111-4111-8111-111111111111',
night_start_date: '2026-09-20',
night_end_date: '2026-09-21',
created_at: '2026-09-20T22:05:00+02:00',
updated_at: '2026-09-21T04:14:00+02:00',
revision_id: 'slr_11111111-1111-4111-8111-111111111111',
sleep_quality: null,
wake_quality: null,
day_form: null,
treatment_and_notes: '',
publication_status: 'ready',
events: [
{ event_id: 'sle_bed', type: 'bed_time', start_at: '2026-09-20T22:05:00+02:00', end_at: null },
...(withSleep ? [{ event_id: 'sle_sleep', type: 'sleep' as const,
start_at: '2026-09-20T22:50:00+02:00', end_at: '2026-09-21T04:04:00+02:00' }] : []),
{ event_id: 'sle_wake_1', type: 'night_get_up', start_at: '2026-09-21T01:35:00+02:00', end_at: null },
{ event_id: 'sle_wake_2', type: 'night_get_up', start_at: '2026-09-21T04:11:00+02:00', end_at: null },
{ event_id: 'sle_up', type: 'final_get_up', start_at: '2026-09-21T04:14:00+02:00', end_at: null },
{ event_id: 'sle_day', type: 'daytime_sleepiness', start_at: '2026-09-21T15:00:00+02:00', end_at: null },
],
intakes: [
{ intake_id: 'mdi_1', medication_id: 'med_1', medication_name: 'Venlafaxine',
taken_at: '2026-09-20T21:55:00+02:00', dose_value: 75, dose_unit: 'mg',
quantity: 2, note: '', created_at: '2026-09-20T21:55:00+02:00' },
{ intake_id: 'mdi_2', medication_id: 'med_2', medication_name: 'Mélatonine',
taken_at: '2026-09-20T22:00:00+02:00', dose_value: 1.9, dose_unit: 'mg',
quantity: 1, note: '', created_at: '2026-09-20T22:00:00+02:00' },
{ intake_id: 'mdi_day', medication_id: 'med_3', medication_name: 'Médikinet',
taken_at: '2026-09-21T12:30:00+02:00', dose_value: 30, dose_unit: 'mg',
quantity: 1, note: '', created_at: '2026-09-21T12:30:00+02:00' },
],
})
it('renders factual BPM statistics, RR, markers and guided target', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
api_version: 1,
@ -80,4 +115,57 @@ describe('HeartRateTimelinePanel', () => {
render(<HeartRateTimelinePanel contextId="sl_11111111-1111-4111-8111-111111111111" />)
expect(await screen.findByText(/Aucune capture cardio synchronisée/)).toBeInTheDocument()
})
it('overlays factual sleep, medications, close awakenings, final get-up and hourly ticks', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
api_version: 1,
context_id: sleepEntry().entry_id,
available: true,
capture: {
capture_id: 'hrc_11111111-1111-4111-8111-111111111111',
context_kind: 'sleep',
started_at: '2026-09-20T22:05:00+02:00',
ended_at: '2026-09-21T04:14:00+02:00',
sensor_name: 'CYCPLUS H2', sample_count: 2,
samples: [
{ sequence: 0, observed_at: '2026-09-20T22:05:00+02:00', bpm: 70, rr_1024: [] },
{ sequence: 1, observed_at: '2026-09-21T04:14:00+02:00', bpm: 60, rr_1024: [] },
],
},
events: [], guidance: null, calibration: null,
}), { status: 200 }))
render(<HeartRateTimelinePanel contextId={sleepEntry().entry_id} sleepEntry={sleepEntry()} />)
await screen.findByTestId('heart-rate-timeline')
expect(screen.getByTestId('heart-rate-sleep-factual')).toBeInTheDocument()
expect(screen.getAllByTestId('heart-rate-medication-marker')).toHaveLength(2)
expect(screen.getAllByTestId('heart-rate-sleep-event-night_get_up')).toHaveLength(2)
expect(screen.getByTestId('heart-rate-sleep-event-final_get_up')).toBeInTheDocument()
expect(screen.queryByTestId('heart-rate-sleep-event-daytime_sleepiness')).not.toBeInTheDocument()
expect(screen.getAllByTestId('heart-rate-hour-tick')).toHaveLength(7)
expect(screen.getByText(/Venlafaxine · 75 mg ×2/)).toBeInTheDocument()
expect(screen.getByText(/Mélatonine · 1.9 mg ×1/)).toBeInTheDocument()
expect(screen.queryByText(/Médikinet/)).not.toBeInTheDocument()
expect(screen.getByRole('img', { name: /Réveil · 01:35/ })).toHaveAttribute('tabindex', '0')
expect(screen.getByRole('img', { name: /Venlafaxine · 21:55 · 75 mg ×2/ })).toHaveAttribute('tabindex', '0')
})
it('keeps the visual fallback without inventing HR when capture is unavailable', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
api_version: 1, context_id: sleepEntry(false).entry_id, available: false,
capture: null, events: [], guidance: null, calibration: null,
}), { status: 200 }))
render(<HeartRateTimelinePanel contextId={sleepEntry(false).entry_id}
sleepEntry={sleepEntry(false)} />)
await screen.findByTestId('heart-rate-timeline')
expect(screen.getByTestId('heart-rate-sleep-estimated')).toHaveTextContent(
/22:50.*04:04/,
)
expect(screen.getByText(/Aucune capture cardio synchronisée ;/)).toBeInTheDocument()
expect(screen.getByText(/Sommeil estimé : mise au lit \+45 min/)).toBeInTheDocument()
expect(document.querySelector('.heart-rate-line')).not.toBeInTheDocument()
})
})

View file

@ -5,6 +5,12 @@ import {
type HeartRateTimeline,
type HeartRateTimelineEvent,
} from '../api/heartRate'
import type { MedicationIntake, SleepEntry, SleepEvent } from '../api/sleepDiary'
import {
hourlyTimelineTicks,
timelineInterval,
timelinePosition,
} from '../routes/sleepTimelineGeometry'
import { heartRateVariations } from './heartRateVariation'
const WIDTH = 1000
@ -76,7 +82,69 @@ function phaseLabel(value: string, french: boolean): string {
return labels[value]?.[french ? 0 : 1] ?? value
}
export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
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()
}
interface SleepRange {
start: number
end: number
estimated: boolean
}
function sleepRanges(entry: SleepEntry): SleepRange[] {
// CONTRACT: recorded Sleep intervals always outrank visualization-only
// estimates. The fallback never mutates the entry or claims a medical
// inference; incomplete bounds deliberately produce no range.
const factual = entry.events
.filter((event) => event.type === 'sleep' && event.end_at !== null)
.map((event) => ({
start: Date.parse(event.start_at),
end: Date.parse(event.end_at as string),
estimated: false,
}))
.filter((range) => Number.isFinite(range.start) && range.end > range.start)
if (factual.length > 0) return factual
const bedTime = entry.events.find((event) => event.type === 'bed_time')
const finalGetUp = entry.events.find((event) => event.type === 'final_get_up')
if (!bedTime || !finalGetUp) return []
const start = Date.parse(bedTime.start_at) + 45 * 60 * 1000
const end = Date.parse(finalGetUp.start_at) - 10 * 60 * 1000
return Number.isFinite(start) && Number.isFinite(end) && end > start
? [{ start, end, estimated: true }]
: []
}
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)
@ -112,6 +180,8 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
}, [timeline])
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
if (failed) {
return <section className="heart-rate-panel" role="alert">
{french ? 'Données cardio momentanément indisponibles.' : 'Heart-rate data is temporarily unavailable.'}
@ -122,7 +192,7 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
{french ? 'Chargement de la courbe cardio…' : 'Loading heart-rate timeline…'}
</section>
}
if (!timeline.available || !timeline.capture || !calculated) {
if ((!timeline.available || !timeline.capture || !calculated) && !sleepEntry) {
return <section className="heart-rate-panel">
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
<p className="analysis-empty">
@ -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 <section className="heart-rate-panel" data-testid="heart-rate-timeline">
<div className="heart-rate-heading">
@ -153,17 +256,54 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
<p className="eyebrow">{french ? 'DONNÉES MESURÉES' : 'MEASURED DATA'}</p>
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
</div>
<dl className="heart-rate-facts">
{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>
</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>
})}
{visibleSleepEvents.filter((event) => event.type === 'long_awake' && event.end_at).map((event) => {
const interval = timelineInterval(event.start_at, event.end_at as string, 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)
@ -173,13 +313,27 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
return <rect key={phase.phase_id} className="heart-rate-target"
x={phaseX} y={targetTop} width={phaseWidth} height={targetHeight} />
})}
{timeline.events.filter((event) => event.end_at).map((event, index) => {
{!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} />
})}
{calculated.variations.map((variation, index) => {
{visibleSleepEvents.filter((event) => event.end_at && event.type !== 'sleep' &&
event.type !== 'long_awake').map((event) => {
const interval = timelineInterval(event.start_at, event.end_at as string, 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'
@ -193,7 +347,7 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
})}
<line className="chart-axis" x1={LEFT} y1={HEIGHT - BOTTOM}
x2={WIDTH - RIGHT} y2={HEIGHT - BOTTOM} />
<polyline className="heart-rate-line" points={points} fill="none" />
{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}>
@ -203,31 +357,83 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
y={TOP + 12 + (index % 3) * 13}>{eventLabel(event, french)}</text>
</g>
})}
{visibleSleepEvents.filter((event) => event.end_at === null && event.type !== 'sleep')
.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}>
{new Date(start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{clock(start)}
</text>
<text className="chart-axis-label" textAnchor="end" x={WIDTH - RIGHT} y={HEIGHT - 10}>
{new Date(end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</text>
<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}
{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.samples.length > calculated.display.length &&
{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>
<p className="heart-rate-variation-legend">
{!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>
</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>

View file

@ -908,7 +908,10 @@ export function SleepDiaryWorkspace({
{selectedEntry && (
<div className="analysis-wide">
<HeartRateTimelinePanel contextId={selectedEntry.entry_id} />
<HeartRateTimelinePanel
contextId={selectedEntry.entry_id}
sleepEntry={selectedEntry}
/>
</div>
)}

View file

@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
hourlyTimelineTicks,
sleepTimelineInterval,
sleepTimelinePosition,
timelineInterval,
timelinePosition,
} from "./sleepTimelineGeometry";
describe("sleep timeline geometry", () => {
@ -40,4 +43,31 @@ describe("sleep timeline geometry", () => {
),
).toBeCloseTo(0.5, 12);
});
it("projects arbitrary chart windows with the same bounded formula", () => {
const start = Date.parse("2026-09-20T22:05:00+02:00");
const end = Date.parse("2026-09-21T04:14:00+02:00");
expect(timelinePosition(start, start, end)).toBe(0);
expect(timelinePosition(end, start, end)).toBe(1);
const interval = timelineInterval(
"2026-09-20T22:50:00+02:00",
"2026-09-21T04:04:00+02:00",
start,
end,
);
expect(interval.left).toBeCloseTo(45 / 369, 12);
expect(interval.width).toBeCloseTo(314 / 369, 12);
});
it("keeps real bounds and emits every intervening full hour", () => {
const start = Date.parse("2026-09-20T22:05:00+02:00");
const end = Date.parse("2026-09-21T04:14:00+02:00");
const ticks = hourlyTimelineTicks(start, end);
expect(ticks.map((tick) => new Date(tick.timestamp).getHours())).toEqual([
23, 0, 1, 2, 3, 4,
]);
expect(ticks.every((tick) => tick.position > 0 && tick.position < 1)).toBe(
true,
);
});
});

View file

@ -1,5 +1,63 @@
export const SLEEP_TIMELINE_DURATION_MS = 24 * 60 * 60 * 1000;
export interface TimelineTick {
timestamp: number;
position: number;
}
export function timelinePosition(
timestamp: string | number,
start: string | number,
end: string | number,
): number {
// INVARIANT: agenda rows, HR samples, factual overlays, estimates, and hour
// ticks all use this normalized projection. Presentation code may scale the
// result but must not introduce event-specific pixel offsets.
const eventTime =
typeof timestamp === "number" ? timestamp : new Date(timestamp).getTime();
const startTime = typeof start === "number" ? start : new Date(start).getTime();
const endTime = typeof end === "number" ? end : new Date(end).getTime();
const duration = endTime - startTime;
if (
!Number.isFinite(eventTime) ||
!Number.isFinite(startTime) ||
!Number.isFinite(endTime) ||
duration <= 0
) {
return 0;
}
return Math.max(0, Math.min(1, (eventTime - startTime) / duration));
}
export function timelineInterval(
intervalStart: string | number,
intervalEnd: string | number,
timelineStart: string | number,
timelineEnd: string | number,
): { left: number; width: number } {
const left = timelinePosition(intervalStart, timelineStart, timelineEnd);
const right = timelinePosition(intervalEnd, timelineStart, timelineEnd);
return { left, width: Math.max(0, right - left) };
}
export function hourlyTimelineTicks(start: number, end: number): TimelineTick[] {
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return [];
const firstHour = new Date(start);
firstHour.setMinutes(0, 0, 0);
firstHour.setHours(firstHour.getHours() + 1);
const ticks: TimelineTick[] = [];
// WHY: advance in elapsed hours so ticks remain monotonic through offset
// changes; start/end still retain their exact factual positions.
for (
let timestamp = firstHour.getTime();
timestamp < end;
timestamp += 60 * 60 * 1000
) {
ticks.push({ timestamp, position: timelinePosition(timestamp, start, end) });
}
return ticks;
}
export function sleepTimelinePosition(
timestamp: string,
nightStartDate: string,
@ -15,9 +73,10 @@ export function sleepTimelinePosition(
).getTime();
const eventTime = new Date(timestamp).getTime();
if (!Number.isFinite(timelineStart) || !Number.isFinite(eventTime)) return 0;
return Math.max(
0,
Math.min(1, (eventTime - timelineStart) / SLEEP_TIMELINE_DURATION_MS),
return timelinePosition(
eventTime,
timelineStart,
timelineStart + SLEEP_TIMELINE_DURATION_MS,
);
}
@ -27,11 +86,15 @@ export function sleepTimelineInterval(
nightStartDate: string,
timelineOffsetSource = startAt,
): { left: number; width: number } {
const left = sleepTimelinePosition(
const timelineOffset =
timelineOffsetSource.match(/(Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
const timelineStart = new Date(
`${nightStartDate}T18:00:00${timelineOffset}`,
).getTime();
return timelineInterval(
startAt,
nightStartDate,
timelineOffsetSource,
endAt,
timelineStart,
timelineStart + SLEEP_TIMELINE_DURATION_MS,
);
const end = sleepTimelinePosition(endAt, nightStartDate, timelineOffsetSource);
return { left, width: Math.max(0, end - left) };
}

View file

@ -3103,6 +3103,28 @@ h2 {
.heart-rate-event-band {
fill: rgb(180 190 254 / 6%);
}
.heart-rate-sleep-band {
fill: rgb(166 227 161 / 13%);
stroke: rgb(166 227 161 / 42%);
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}
.heart-rate-sleep-estimated {
fill: rgb(148 226 213 / 8%);
stroke: rgb(148 226 213 / 55%);
stroke-dasharray: 5 3;
}
.heart-rate-awake-band {
fill: rgb(249 226 175 / 14%);
stroke: rgb(249 226 175 / 55%);
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}
.heart-rate-hour-grid {
stroke: rgb(127 132 156 / 28%);
stroke-width: 0.6;
vector-effect: non-scaling-stroke;
}
.heart-rate-variation {
opacity: 0.2;
pointer-events: auto;
@ -3123,6 +3145,31 @@ h2 {
stroke-dasharray: 3 3;
vector-effect: non-scaling-stroke;
}
.heart-rate-sleep-marker {
stroke: var(--yellow);
stroke-width: 1.2;
stroke-dasharray: 4 2;
vector-effect: non-scaling-stroke;
}
.heart-rate-sleep-marker-bed_time {
stroke: var(--mauve);
}
.heart-rate-sleep-marker-final_get_up {
stroke: var(--peach);
stroke-width: 1.8;
}
.heart-rate-medication-marker {
fill: var(--blue);
stroke: var(--base);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.heart-rate-medication-label {
fill: var(--base);
font-size: 8px;
font-weight: 800;
pointer-events: none;
}
.heart-rate-marker-label,
.chart-axis-label {
fill: var(--subtext-0);
@ -3133,6 +3180,27 @@ h2 {
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.heart-rate-estimation-note,
.heart-rate-overlay-list {
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.heart-rate-estimation-note {
padding-left: 0.6rem;
border-left: 2px dashed var(--teal);
}
.heart-rate-overlay-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1rem;
margin: 0.65rem 0 0;
padding: 0;
list-style: none;
}
.heart-rate-overlay-list time {
color: var(--blue);
font-family: var(--font-mono);
}
.heart-rate-phases,
.heart-rate-calibration,
.heart-rate-events {
@ -3176,4 +3244,10 @@ h2 {
grid-template-columns: 1fr;
gap: 0.2rem;
}
.heart-rate-chart {
overflow-x: auto;
}
.heart-rate-chart svg {
min-width: 44rem;
}
}