diff --git a/CHANGELOG.md b/CHANGELOG.md index c7da0f6..6bc2a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index 74f4323..3b2b376 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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 diff --git a/docs/reviews/evidence/sleep-diary-alignment-firefox.png b/docs/reviews/evidence/sleep-diary-alignment-firefox.png index 96cbd45..93f638f 100644 Binary files a/docs/reviews/evidence/sleep-diary-alignment-firefox.png and b/docs/reviews/evidence/sleep-diary-alignment-firefox.png differ diff --git a/docs/reviews/evidence/sleep-diary-empty-night-firefox.png b/docs/reviews/evidence/sleep-diary-empty-night-firefox.png index 24fb54e..7e8e9ac 100644 Binary files a/docs/reviews/evidence/sleep-diary-empty-night-firefox.png and b/docs/reviews/evidence/sleep-diary-empty-night-firefox.png differ diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.pdf b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf index d8f1995..d105a62 100644 --- a/docs/reviews/evidence/sleep-diary-fr-7-days.pdf +++ b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf @@ -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 diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.png b/docs/reviews/evidence/sleep-diary-fr-7-days.png index 5b45a33..c6ef424 100644 Binary files a/docs/reviews/evidence/sleep-diary-fr-7-days.png and b/docs/reviews/evidence/sleep-diary-fr-7-days.png differ diff --git a/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png index a3b6dcb..f181b89 100644 Binary files a/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png and b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png differ diff --git a/docs/roadmap.md b/docs/roadmap.md index 9645b14..d45a55f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. ``` diff --git a/docs/tests.md b/docs/tests.md index c5ee695..4bc7ff9 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 diff --git a/tests/test_web_sleep_browser.py b/tests/test_web_sleep_browser.py index ea3093a..b7fc6b8 100644 --- a/tests/test_web_sleep_browser.py +++ b/tests/test_web_sleep_browser.py @@ -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" diff --git a/tui/meson.build b/tui/meson.build index d295525..6f8d865 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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', diff --git a/web/src/components/HeartRateTimelinePanel.test.tsx b/web/src/components/HeartRateTimelinePanel.test.tsx index c28b703..b2fecfe 100644 --- a/web/src/components/HeartRateTimelinePanel.test.tsx +++ b/web/src/components/HeartRateTimelinePanel.test.tsx @@ -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() 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() + + 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() + + 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() + }) }) diff --git a/web/src/components/HeartRateTimelinePanel.tsx b/web/src/components/HeartRateTimelinePanel.tsx index 63bbbf9..9dbf222 100644 --- a/web/src/components/HeartRateTimelinePanel.tsx +++ b/web/src/components/HeartRateTimelinePanel.tsx @@ -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(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
{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…'}
} - if (!timeline.available || !timeline.capture || !calculated) { + if ((!timeline.available || !timeline.capture || !calculated) && !sleepEntry) { return

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

@@ -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

@@ -153,17 +256,54 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {

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

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

-
+ {calculated &&
Min
{calculated.minimum} BPM
{french ? 'Moy.' : 'Avg.'}
{calculated.average.toFixed(1)} BPM
Max
{calculated.maximum} BPM
RR
{calculated.rrCount}
-
+
}
+ {sleep.map((range, index) => { + const interval = timelineInterval(range.start, range.end, start, end) + const rangeX = LEFT + interval.left * (WIDTH - LEFT - RIGHT) + const width = Math.max(1, interval.width * (WIDTH - LEFT - RIGHT)) + const title = range.estimated + ? (french ? 'Sommeil estimé (repère visuel, non médical)' : 'Estimated sleep (visual guide, not medical)') + : (french ? 'Sommeil déclaré' : 'Declared sleep') + return + {`${title} · ${clock(range.start)}–${clock(range.end)}`} + + })} + {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 + {title} + + })} + {hourlyTicks.map((tick) => { + const tickX = LEFT + tick.position * (WIDTH - LEFT - RIGHT) + return + + {clock(tick.timestamp)} + + })} {timeline.guidance?.phases.map((phase) => { if (!phase.target) return null const phaseX = x(phase.started_at) @@ -173,13 +313,27 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) { return })} - {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 })} - {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 + {title} + + })} + {(calculated?.variations ?? []).map((variation, index) => { const variationX = x(variation.started_at) const width = Math.max(2, x(variation.ended_at) - variationX) const label = variation.direction === 'rise' @@ -193,7 +347,7 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) { })} - + {points.length > 0 && } {visibleEvents.map((event, index) => { const eventX = x(event.at) return @@ -203,31 +357,83 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) { y={TOP + 12 + (index % 3) * 13}>{eventLabel(event, french)} })} + {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 + + {title} + + WIDTH - 150 ? 'end' : 'start'} + x={eventX + (eventX > WIDTH - 150 ? -4 : 4)} y={TOP + 12 + (index % 3) * 13}> + {eventLabel({ type: event.type } as HeartRateTimelineEvent, french)} + {title} + + + })} + {visibleSleepIntakes.map((intake, index) => { + const intakeX = x(intake.taken_at) + const title = `${intake.medication_name} · ${clock(intake.taken_at)} · ${ + intake.dose_value === null ? (french ? 'dose non renseignée' : 'dose not provided') : + `${intake.dose_value} ${intake.dose_unit ?? ''} ×${intake.quantity}` + }` + return + + {title} + + M{title} + + })} - {new Date(start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {clock(start)} - {new Date(end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - - - {calculated.maximum} - - - {calculated.minimum} + {clock(end)} + {calculated && <> + + {calculated.maximum} + + + {calculated.minimum} + + } - {capture.samples.length > calculated.display.length && + {capture && calculated && capture.samples.length > calculated.display.length &&
{french ? 'Projection graphique bornée conservant les minima/maxima ; statistiques calculées sur toutes les mesures.' : 'Bounded chart projection preserves minima/maxima; statistics use every measurement.'}
}
-

+ {!calculated &&

+ {french + ? 'Aucune capture cardio synchronisée ; les repères Sleep restent affichés sans inventer de mesures.' + : 'No synchronized heart-rate capture; Sleep markers remain visible without invented measurements.'} +

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

+ {french + ? 'Sommeil estimé : mise au lit +45 min → lever final −10 min. Repère visuel non médical ; les données Sleep ne sont pas modifiées.' + : 'Estimated sleep: bedtime +45 min → final get-up −10 min. Non-medical visual guide; Sleep data is unchanged.'} +

} + {calculated &&

{french ? 'Rouge : hausse notable · pêche : baisse notable. Variations relatives mesurées, sans inférence sur le sommeil ni leur cause.' : 'Red: notable rise · peach: notable fall. Relative measured variations, with no sleep-state or cause inference.'} -

+

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

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

diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index f95013e..a3c8902 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -908,7 +908,10 @@ export function SleepDiaryWorkspace({ {selectedEntry && (
- +
)} diff --git a/web/src/routes/sleepTimelineGeometry.test.ts b/web/src/routes/sleepTimelineGeometry.test.ts index 419d7e6..2be77f6 100644 --- a/web/src/routes/sleepTimelineGeometry.test.ts +++ b/web/src/routes/sleepTimelineGeometry.test.ts @@ -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, + ); + }); }); diff --git a/web/src/routes/sleepTimelineGeometry.ts b/web/src/routes/sleepTimelineGeometry.ts index 03ede00..d0e0954 100644 --- a/web/src/routes/sleepTimelineGeometry.ts +++ b/web/src/routes/sleepTimelineGeometry.ts @@ -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) }; } diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 0fa61e7..a6221c4 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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; + } }