feat(web): enrich sleep heart-rate timeline overlays
|
|
@ -34,6 +34,14 @@ also refined.
|
||||||
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
|
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
|
||||||
through import, ACK, replay and a second Sleep-only generation with no
|
through import, ACK, replay and a second Sleep-only generation with no
|
||||||
Android workout session.
|
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
|
## 0.1.5 — 2026-09-23
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
variation only (five-minute trailing reference excluding the current 60-second
|
||||||
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
||||||
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
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
|
Language is device-local presentation state. Android keeps it in dedicated
|
||||||
SharedPreferences; desktop keeps it in the XDG configuration file
|
SharedPreferences; desktop keeps it in the XDG configuration file
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 137 KiB |
|
|
@ -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 >>
|
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
|
||||||
endobj
|
endobj
|
||||||
5 0 obj
|
5 0 obj
|
||||||
<< /Length 17659 >>
|
<< /Length 17665 >>
|
||||||
stream
|
stream
|
||||||
0 G 0 g
|
0 G 0 g
|
||||||
0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET
|
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 552.0 Td (TRAITEMENT ET) Tj ET
|
||||||
0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) 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.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 525.0 Td (23/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 515.0 Td (au 24/09/2026) Tj ET
|
||||||
0.9 G 112 498 m 112 538 l S
|
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 132.83333333333334 498 m 132.83333333333334 538 l S
|
||||||
0.9 G 153.66666666666666 498 m 153.66666666666666 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 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 658.0 522.0 Td (-) Tj ET
|
||||||
0 g BT /F1 7 Tf 696.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 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 (venlafaxine \227 75 mg) 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 513.0 Td () Tj ET
|
||||||
0 g BT /F1 4 Tf 730.0 504.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
|
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 570.3333333333333 458 m 570.3333333333333 498 l S
|
||||||
0.9 G 591.1666666666667 458 m 591.1666666666667 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.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.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 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 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 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 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 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 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 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 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 (ntr\364le g\351om\351trique.) 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 473.0 Td () Tj ET
|
||||||
0 g BT /F1 4 Tf 730.0 464.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
|
0.7 G 30 418 782 40 re S
|
||||||
|
|
@ -383,5 +383,5 @@ xref
|
||||||
trailer
|
trailer
|
||||||
<< /Size 6 /Root 1 0 R >>
|
<< /Size 6 /Root 1 0 R >>
|
||||||
startxref
|
startxref
|
||||||
18059
|
18065
|
||||||
%%EOF
|
%%EOF
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 139 KiB |
|
|
@ -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
|
The heart-rate path remains independent from the frozen Bluetooth Trainlog
|
||||||
synchronization transport.
|
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
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=0.1.6 post-review Web corrections validated and privately deployed; stable release work remains separate.
|
CURRENT_OPERATIONAL_CURSOR=0.1.6 post-review Web corrections validated and privately deployed; stable release work remains separate.
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
night state, return navigation and rejection of an earlier date response that
|
||||||
finishes late. Localized dose/unit labels, same-name medications with different
|
finishes late. Localized dose/unit labels, same-name medications with different
|
||||||
usual doses and editable intake-dose prefill remain covered.
|
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
|
`test_web_sleep_browser.py` exercises the embedded production bundle in real
|
||||||
Firefox against an isolated synthetic database, observes every mutation
|
Firefox against an isolated synthetic database, observes every mutation
|
||||||
without reload, verifies HTTP success and browser error collectors, measures
|
without reload, verifies HTTP success and browser error collectors, measures
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
"taken_at": "2026-09-19T22:30:00+02:00",
|
"taken_at": "2026-09-19T22:30:00+02:00",
|
||||||
"dose_value": 75,
|
"dose_value": 75,
|
||||||
"dose_unit": "mg",
|
"dose_unit": "mg",
|
||||||
|
"quantity": 2,
|
||||||
"note": "",
|
"note": "",
|
||||||
"created_at": "2026-09-19T22:30:00+02:00",
|
"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']"
|
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(
|
geometry = driver.execute_script(
|
||||||
"""
|
"""
|
||||||
const axis=document.querySelector('.sleep-hour-axis').getBoundingClientRect();
|
const axis=document.querySelector('.sleep-hour-axis').getBoundingClientRect();
|
||||||
|
|
@ -400,6 +431,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
return {
|
return {
|
||||||
label:value.textContent,
|
label:value.textContent,
|
||||||
center:tick.left + tick.width / 2,
|
center:tick.left + tick.width / 2,
|
||||||
|
left:value.style.left,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|
@ -428,12 +460,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
expected_x, geometry[key], delta=1.0, msg=key
|
expected_x, geometry[key], delta=1.0, msg=key
|
||||||
)
|
)
|
||||||
for tick_index in (4, 9, 10, 11, 21, 22):
|
agenda_tick_hours = (0, 6, 12, 18, 24)
|
||||||
expected_x = geometry["left"] + geometry["width"] * tick_index / 24
|
self.assertEqual(
|
||||||
self.assertAlmostEqual(
|
["18:00", "00:00", "06:00", "12:00", "18:00"],
|
||||||
expected_x,
|
[tick["label"] for tick in geometry["ticks"]],
|
||||||
geometry["ticks"][tick_index]["center"],
|
)
|
||||||
delta=1.0,
|
self.assertEqual(
|
||||||
|
[f"{tick_hour / 24 * 100:g}%" for tick_hour in agenda_tick_hours],
|
||||||
|
[tick["left"] for tick in geometry["ticks"]],
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
driver.save_screenshot(
|
driver.save_screenshot(
|
||||||
|
|
@ -441,10 +475,12 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
set_value("sleep-night-start", "2026-09-21")
|
set_value("sleep-night-start", "2025-01-01")
|
||||||
wait.until(
|
wait.until(
|
||||||
lambda current: "Aucune donnée pour cette nuit."
|
lambda current: current.find_element(
|
||||||
in current.page_source
|
By.CSS_SELECTOR, "[data-testid='sleep-night-start']"
|
||||||
|
).get_attribute("value")
|
||||||
|
== "2025-01-01"
|
||||||
)
|
)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
driver.find_elements(
|
driver.find_elements(
|
||||||
|
|
@ -599,7 +635,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
|
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
== 1
|
== 7
|
||||||
)
|
)
|
||||||
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
||||||
download_path = EVIDENCE / "trainlog-sleep-diary.pdf"
|
download_path = EVIDENCE / "trainlog-sleep-diary.pdf"
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ if web_assets_enabled
|
||||||
'../web/src/api/health.ts',
|
'../web/src/api/health.ts',
|
||||||
'../web/src/api/dashboard.ts',
|
'../web/src/api/dashboard.ts',
|
||||||
'../web/src/api/analysis.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/sleepDiary.ts',
|
||||||
'../web/src/api/analysis.test.ts',
|
'../web/src/api/analysis.test.ts',
|
||||||
'../web/src/analysis/messages.ts',
|
'../web/src/analysis/messages.ts',
|
||||||
|
|
@ -64,6 +66,10 @@ if web_assets_enabled
|
||||||
'../web/src/components/DeleteConfirmationDialog.tsx',
|
'../web/src/components/DeleteConfirmationDialog.tsx',
|
||||||
'../web/src/components/Footer.tsx',
|
'../web/src/components/Footer.tsx',
|
||||||
'../web/src/components/Header.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.tsx',
|
||||||
'../web/src/components/SyncControl.test.tsx',
|
'../web/src/components/SyncControl.test.tsx',
|
||||||
'../web/src/components/Tile.tsx',
|
'../web/src/components/Tile.tsx',
|
||||||
|
|
@ -80,6 +86,8 @@ if web_assets_enabled
|
||||||
'../web/src/routes/DashboardPage.tsx',
|
'../web/src/routes/DashboardPage.tsx',
|
||||||
'../web/src/routes/AnalysisPage.tsx',
|
'../web/src/routes/AnalysisPage.tsx',
|
||||||
'../web/src/routes/SleepDiaryWorkspace.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.ts',
|
||||||
'../web/src/report/sleepDiaryPdf.test.ts',
|
'../web/src/report/sleepDiaryPdf.test.ts',
|
||||||
'../web/src/routes/AnalysisPage.test.tsx',
|
'../web/src/routes/AnalysisPage.test.tsx',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,45 @@
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { HeartRateTimelinePanel } from './HeartRateTimelinePanel'
|
import { HeartRateTimelinePanel } from './HeartRateTimelinePanel'
|
||||||
|
import type { SleepEntry } from '../api/sleepDiary'
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks())
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
describe('HeartRateTimelinePanel', () => {
|
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 () => {
|
it('renders factual BPM statistics, RR, markers and guided target', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
||||||
api_version: 1,
|
api_version: 1,
|
||||||
|
|
@ -80,4 +115,57 @@ describe('HeartRateTimelinePanel', () => {
|
||||||
render(<HeartRateTimelinePanel contextId="sl_11111111-1111-4111-8111-111111111111" />)
|
render(<HeartRateTimelinePanel contextId="sl_11111111-1111-4111-8111-111111111111" />)
|
||||||
expect(await screen.findByText(/Aucune capture cardio synchronisée/)).toBeInTheDocument()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import {
|
||||||
type HeartRateTimeline,
|
type HeartRateTimeline,
|
||||||
type HeartRateTimelineEvent,
|
type HeartRateTimelineEvent,
|
||||||
} from '../api/heartRate'
|
} from '../api/heartRate'
|
||||||
|
import type { MedicationIntake, SleepEntry, SleepEvent } from '../api/sleepDiary'
|
||||||
|
import {
|
||||||
|
hourlyTimelineTicks,
|
||||||
|
timelineInterval,
|
||||||
|
timelinePosition,
|
||||||
|
} from '../routes/sleepTimelineGeometry'
|
||||||
import { heartRateVariations } from './heartRateVariation'
|
import { heartRateVariations } from './heartRateVariation'
|
||||||
|
|
||||||
const WIDTH = 1000
|
const WIDTH = 1000
|
||||||
|
|
@ -76,7 +82,69 @@ function phaseLabel(value: string, french: boolean): string {
|
||||||
return labels[value]?.[french ? 0 : 1] ?? value
|
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 french = !document.documentElement.lang.toLowerCase().startsWith('en')
|
||||||
const [timeline, setTimeline] = useState<HeartRateTimeline | null>(null)
|
const [timeline, setTimeline] = useState<HeartRateTimeline | null>(null)
|
||||||
const [failed, setFailed] = useState(false)
|
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) }
|
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
||||||
}, [timeline])
|
}, [timeline])
|
||||||
|
|
||||||
|
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
return <section className="heart-rate-panel" role="alert">
|
return <section className="heart-rate-panel" role="alert">
|
||||||
{french ? 'Données cardio momentanément indisponibles.' : 'Heart-rate data is temporarily unavailable.'}
|
{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…'}
|
{french ? 'Chargement de la courbe cardio…' : 'Loading heart-rate timeline…'}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
if (!timeline.available || !timeline.capture || !calculated) {
|
if ((!timeline.available || !timeline.capture || !calculated) && !sleepEntry) {
|
||||||
return <section className="heart-rate-panel">
|
return <section className="heart-rate-panel">
|
||||||
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
|
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
|
||||||
<p className="analysis-empty">
|
<p className="analysis-empty">
|
||||||
|
|
@ -132,20 +202,53 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const capture = timeline.capture
|
const capture = timeline.capture
|
||||||
const start = Date.parse(capture.started_at)
|
const sleepEvents = sleepEntry?.events ?? []
|
||||||
const end = Date.parse(capture.ended_at)
|
const sleepIntakes = sleepEntry?.intakes ?? []
|
||||||
const duration = Math.max(1, end - start)
|
const bedTime = sleepEvents.find((event) => event.type === 'bed_time')
|
||||||
const bpmFloor = Math.max(0, calculated.minimum - 8)
|
const finalGetUp = sleepEvents.find((event) => event.type === 'final_get_up')
|
||||||
const bpmCeiling = calculated.maximum + 8
|
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 bpmSpan = Math.max(1, bpmCeiling - bpmFloor)
|
||||||
const x = (timestamp: string) =>
|
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) =>
|
const y = (bpm: number) =>
|
||||||
TOP + ((bpmCeiling - bpm) / bpmSpan) * (HEIGHT - TOP - BOTTOM)
|
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)))
|
.map((sample) => String(x(sample.observed_at)) + ',' + String(y(sample.bpm)))
|
||||||
.join(' ')
|
.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">
|
return <section className="heart-rate-panel" data-testid="heart-rate-timeline">
|
||||||
<div className="heart-rate-heading">
|
<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>
|
<p className="eyebrow">{french ? 'DONNÉES MESURÉES' : 'MEASURED DATA'}</p>
|
||||||
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
|
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
|
||||||
</div>
|
</div>
|
||||||
<dl className="heart-rate-facts">
|
{calculated && <dl className="heart-rate-facts">
|
||||||
<div><dt>Min</dt><dd>{calculated.minimum} BPM</dd></div>
|
<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>{french ? 'Moy.' : 'Avg.'}</dt><dd>{calculated.average.toFixed(1)} BPM</dd></div>
|
||||||
<div><dt>Max</dt><dd>{calculated.maximum} BPM</dd></div>
|
<div><dt>Max</dt><dd>{calculated.maximum} BPM</dd></div>
|
||||||
<div><dt>RR</dt><dd>{calculated.rrCount}</dd></div>
|
<div><dt>RR</dt><dd>{calculated.rrCount}</dd></div>
|
||||||
</dl>
|
</dl>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<figure className="heart-rate-chart">
|
<figure className="heart-rate-chart">
|
||||||
<svg viewBox={'0 0 ' + WIDTH + ' ' + HEIGHT} role="img"
|
<svg viewBox={'0 0 ' + WIDTH + ' ' + HEIGHT} role="img"
|
||||||
aria-label={french ? 'Courbe du rythme cardiaque mesuré' : 'Measured heart-rate curve'}>
|
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) => {
|
{timeline.guidance?.phases.map((phase) => {
|
||||||
if (!phase.target) return null
|
if (!phase.target) return null
|
||||||
const phaseX = x(phase.started_at)
|
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"
|
return <rect key={phase.phase_id} className="heart-rate-target"
|
||||||
x={phaseX} y={targetTop} width={phaseWidth} height={targetHeight} />
|
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 eventX = x(event.at)
|
||||||
const width = Math.max(1, x(event.end_at as string) - eventX)
|
const width = Math.max(1, x(event.end_at as string) - eventX)
|
||||||
return <rect key={'interval-' + index} className="heart-rate-event-band"
|
return <rect key={'interval-' + index} className="heart-rate-event-band"
|
||||||
x={eventX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM} />
|
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 variationX = x(variation.started_at)
|
||||||
const width = Math.max(2, x(variation.ended_at) - variationX)
|
const width = Math.max(2, x(variation.ended_at) - variationX)
|
||||||
const label = variation.direction === 'rise'
|
const label = variation.direction === 'rise'
|
||||||
|
|
@ -193,7 +347,7 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
||||||
})}
|
})}
|
||||||
<line className="chart-axis" x1={LEFT} y1={HEIGHT - BOTTOM}
|
<line className="chart-axis" x1={LEFT} y1={HEIGHT - BOTTOM}
|
||||||
x2={WIDTH - RIGHT} y2={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) => {
|
{visibleEvents.map((event, index) => {
|
||||||
const eventX = x(event.at)
|
const eventX = x(event.at)
|
||||||
return <g key={'marker-' + index}>
|
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>
|
y={TOP + 12 + (index % 3) * 13}>{eventLabel(event, french)}</text>
|
||||||
</g>
|
</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}>
|
<text className="chart-axis-label" x={LEFT} y={HEIGHT - 10}>
|
||||||
{new Date(start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{clock(start)}
|
||||||
</text>
|
</text>
|
||||||
<text className="chart-axis-label" textAnchor="end" x={WIDTH - RIGHT} y={HEIGHT - 10}>
|
<text className="chart-axis-label" textAnchor="end" x={WIDTH - RIGHT} y={HEIGHT - 10}>
|
||||||
{new Date(end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{clock(end)}
|
||||||
</text>
|
</text>
|
||||||
|
{calculated && <>
|
||||||
<text className="chart-axis-label" x={4} y={y(calculated.maximum) + 4}>
|
<text className="chart-axis-label" x={4} y={y(calculated.maximum) + 4}>
|
||||||
{calculated.maximum}
|
{calculated.maximum}
|
||||||
</text>
|
</text>
|
||||||
<text className="chart-axis-label" x={4} y={y(calculated.minimum) + 4}>
|
<text className="chart-axis-label" x={4} y={y(calculated.minimum) + 4}>
|
||||||
{calculated.minimum}
|
{calculated.minimum}
|
||||||
</text>
|
</text>
|
||||||
|
</>}
|
||||||
</svg>
|
</svg>
|
||||||
{capture.samples.length > calculated.display.length &&
|
{capture && calculated && capture.samples.length > calculated.display.length &&
|
||||||
<figcaption>
|
<figcaption>
|
||||||
{french
|
{french
|
||||||
? 'Projection graphique bornée conservant les minima/maxima ; statistiques calculées sur toutes les mesures.'
|
? '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.'}
|
: 'Bounded chart projection preserves minima/maxima; statistics use every measurement.'}
|
||||||
</figcaption>}
|
</figcaption>}
|
||||||
</figure>
|
</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
|
{french
|
||||||
? 'Rouge : hausse notable · pêche : baisse notable. Variations relatives mesurées, sans inférence sur le sommeil ni leur cause.'
|
? '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.'}
|
: '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">
|
{timeline.guidance && <div className="heart-rate-phases">
|
||||||
<h4>{french ? 'Phases guidées' : 'Guided phases'}</h4>
|
<h4>{french ? 'Phases guidées' : 'Guided phases'}</h4>
|
||||||
|
|
|
||||||
|
|
@ -908,7 +908,10 @@ export function SleepDiaryWorkspace({
|
||||||
|
|
||||||
{selectedEntry && (
|
{selectedEntry && (
|
||||||
<div className="analysis-wide">
|
<div className="analysis-wide">
|
||||||
<HeartRateTimelinePanel contextId={selectedEntry.entry_id} />
|
<HeartRateTimelinePanel
|
||||||
|
contextId={selectedEntry.entry_id}
|
||||||
|
sleepEntry={selectedEntry}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
hourlyTimelineTicks,
|
||||||
sleepTimelineInterval,
|
sleepTimelineInterval,
|
||||||
sleepTimelinePosition,
|
sleepTimelinePosition,
|
||||||
|
timelineInterval,
|
||||||
|
timelinePosition,
|
||||||
} from "./sleepTimelineGeometry";
|
} from "./sleepTimelineGeometry";
|
||||||
|
|
||||||
describe("sleep timeline geometry", () => {
|
describe("sleep timeline geometry", () => {
|
||||||
|
|
@ -40,4 +43,31 @@ describe("sleep timeline geometry", () => {
|
||||||
),
|
),
|
||||||
).toBeCloseTo(0.5, 12);
|
).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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,63 @@
|
||||||
export const SLEEP_TIMELINE_DURATION_MS = 24 * 60 * 60 * 1000;
|
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(
|
export function sleepTimelinePosition(
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
nightStartDate: string,
|
nightStartDate: string,
|
||||||
|
|
@ -15,9 +73,10 @@ export function sleepTimelinePosition(
|
||||||
).getTime();
|
).getTime();
|
||||||
const eventTime = new Date(timestamp).getTime();
|
const eventTime = new Date(timestamp).getTime();
|
||||||
if (!Number.isFinite(timelineStart) || !Number.isFinite(eventTime)) return 0;
|
if (!Number.isFinite(timelineStart) || !Number.isFinite(eventTime)) return 0;
|
||||||
return Math.max(
|
return timelinePosition(
|
||||||
0,
|
eventTime,
|
||||||
Math.min(1, (eventTime - timelineStart) / SLEEP_TIMELINE_DURATION_MS),
|
timelineStart,
|
||||||
|
timelineStart + SLEEP_TIMELINE_DURATION_MS,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,11 +86,15 @@ export function sleepTimelineInterval(
|
||||||
nightStartDate: string,
|
nightStartDate: string,
|
||||||
timelineOffsetSource = startAt,
|
timelineOffsetSource = startAt,
|
||||||
): { left: number; width: number } {
|
): { 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,
|
startAt,
|
||||||
nightStartDate,
|
endAt,
|
||||||
timelineOffsetSource,
|
timelineStart,
|
||||||
|
timelineStart + SLEEP_TIMELINE_DURATION_MS,
|
||||||
);
|
);
|
||||||
const end = sleepTimelinePosition(endAt, nightStartDate, timelineOffsetSource);
|
|
||||||
return { left, width: Math.max(0, end - left) };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3103,6 +3103,28 @@ h2 {
|
||||||
.heart-rate-event-band {
|
.heart-rate-event-band {
|
||||||
fill: rgb(180 190 254 / 6%);
|
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 {
|
.heart-rate-variation {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|
@ -3123,6 +3145,31 @@ h2 {
|
||||||
stroke-dasharray: 3 3;
|
stroke-dasharray: 3 3;
|
||||||
vector-effect: non-scaling-stroke;
|
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,
|
.heart-rate-marker-label,
|
||||||
.chart-axis-label {
|
.chart-axis-label {
|
||||||
fill: var(--subtext-0);
|
fill: var(--subtext-0);
|
||||||
|
|
@ -3133,6 +3180,27 @@ h2 {
|
||||||
color: var(--subtext-0);
|
color: var(--subtext-0);
|
||||||
font-size: var(--font-size-xs);
|
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-phases,
|
||||||
.heart-rate-calibration,
|
.heart-rate-calibration,
|
||||||
.heart-rate-events {
|
.heart-rate-events {
|
||||||
|
|
@ -3176,4 +3244,10 @@ h2 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
.heart-rate-chart {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.heart-rate-chart svg {
|
||||||
|
min-width: 44rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||