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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
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 >>
|
||||
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
|
||||
|
|
|
|||
|
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
|
||||
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.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,45 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HeartRateTimelinePanel } from './HeartRateTimelinePanel'
|
||||
import type { SleepEntry } from '../api/sleepDiary'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('HeartRateTimelinePanel', () => {
|
||||
const sleepEntry = (withSleep = true): SleepEntry => ({
|
||||
entry_id: 'sl_11111111-1111-4111-8111-111111111111',
|
||||
night_start_date: '2026-09-20',
|
||||
night_end_date: '2026-09-21',
|
||||
created_at: '2026-09-20T22:05:00+02:00',
|
||||
updated_at: '2026-09-21T04:14:00+02:00',
|
||||
revision_id: 'slr_11111111-1111-4111-8111-111111111111',
|
||||
sleep_quality: null,
|
||||
wake_quality: null,
|
||||
day_form: null,
|
||||
treatment_and_notes: '',
|
||||
publication_status: 'ready',
|
||||
events: [
|
||||
{ event_id: 'sle_bed', type: 'bed_time', start_at: '2026-09-20T22:05:00+02:00', end_at: null },
|
||||
...(withSleep ? [{ event_id: 'sle_sleep', type: 'sleep' as const,
|
||||
start_at: '2026-09-20T22:50:00+02:00', end_at: '2026-09-21T04:04:00+02:00' }] : []),
|
||||
{ event_id: 'sle_wake_1', type: 'night_get_up', start_at: '2026-09-21T01:35:00+02:00', end_at: null },
|
||||
{ event_id: 'sle_wake_2', type: 'night_get_up', start_at: '2026-09-21T04:11:00+02:00', end_at: null },
|
||||
{ event_id: 'sle_up', type: 'final_get_up', start_at: '2026-09-21T04:14:00+02:00', end_at: null },
|
||||
{ event_id: 'sle_day', type: 'daytime_sleepiness', start_at: '2026-09-21T15:00:00+02:00', end_at: null },
|
||||
],
|
||||
intakes: [
|
||||
{ intake_id: 'mdi_1', medication_id: 'med_1', medication_name: 'Venlafaxine',
|
||||
taken_at: '2026-09-20T21:55:00+02:00', dose_value: 75, dose_unit: 'mg',
|
||||
quantity: 2, note: '', created_at: '2026-09-20T21:55:00+02:00' },
|
||||
{ intake_id: 'mdi_2', medication_id: 'med_2', medication_name: 'Mélatonine',
|
||||
taken_at: '2026-09-20T22:00:00+02:00', dose_value: 1.9, dose_unit: 'mg',
|
||||
quantity: 1, note: '', created_at: '2026-09-20T22:00:00+02:00' },
|
||||
{ intake_id: 'mdi_day', medication_id: 'med_3', medication_name: 'Médikinet',
|
||||
taken_at: '2026-09-21T12:30:00+02:00', dose_value: 30, dose_unit: 'mg',
|
||||
quantity: 1, note: '', created_at: '2026-09-21T12:30:00+02:00' },
|
||||
],
|
||||
})
|
||||
|
||||
it('renders factual BPM statistics, RR, markers and guided target', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
||||
api_version: 1,
|
||||
|
|
@ -80,4 +115,57 @@ describe('HeartRateTimelinePanel', () => {
|
|||
render(<HeartRateTimelinePanel contextId="sl_11111111-1111-4111-8111-111111111111" />)
|
||||
expect(await screen.findByText(/Aucune capture cardio synchronisée/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('overlays factual sleep, medications, close awakenings, final get-up and hourly ticks', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
||||
api_version: 1,
|
||||
context_id: sleepEntry().entry_id,
|
||||
available: true,
|
||||
capture: {
|
||||
capture_id: 'hrc_11111111-1111-4111-8111-111111111111',
|
||||
context_kind: 'sleep',
|
||||
started_at: '2026-09-20T22:05:00+02:00',
|
||||
ended_at: '2026-09-21T04:14:00+02:00',
|
||||
sensor_name: 'CYCPLUS H2', sample_count: 2,
|
||||
samples: [
|
||||
{ sequence: 0, observed_at: '2026-09-20T22:05:00+02:00', bpm: 70, rr_1024: [] },
|
||||
{ sequence: 1, observed_at: '2026-09-21T04:14:00+02:00', bpm: 60, rr_1024: [] },
|
||||
],
|
||||
},
|
||||
events: [], guidance: null, calibration: null,
|
||||
}), { status: 200 }))
|
||||
|
||||
render(<HeartRateTimelinePanel contextId={sleepEntry().entry_id} sleepEntry={sleepEntry()} />)
|
||||
|
||||
await screen.findByTestId('heart-rate-timeline')
|
||||
expect(screen.getByTestId('heart-rate-sleep-factual')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('heart-rate-medication-marker')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('heart-rate-sleep-event-night_get_up')).toHaveLength(2)
|
||||
expect(screen.getByTestId('heart-rate-sleep-event-final_get_up')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('heart-rate-sleep-event-daytime_sleepiness')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('heart-rate-hour-tick')).toHaveLength(7)
|
||||
expect(screen.getByText(/Venlafaxine · 75 mg ×2/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Mélatonine · 1.9 mg ×1/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Médikinet/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: /Réveil · 01:35/ })).toHaveAttribute('tabindex', '0')
|
||||
expect(screen.getByRole('img', { name: /Venlafaxine · 21:55 · 75 mg ×2/ })).toHaveAttribute('tabindex', '0')
|
||||
})
|
||||
|
||||
it('keeps the visual fallback without inventing HR when capture is unavailable', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
||||
api_version: 1, context_id: sleepEntry(false).entry_id, available: false,
|
||||
capture: null, events: [], guidance: null, calibration: null,
|
||||
}), { status: 200 }))
|
||||
|
||||
render(<HeartRateTimelinePanel contextId={sleepEntry(false).entry_id}
|
||||
sleepEntry={sleepEntry(false)} />)
|
||||
|
||||
await screen.findByTestId('heart-rate-timeline')
|
||||
expect(screen.getByTestId('heart-rate-sleep-estimated')).toHaveTextContent(
|
||||
/22:50.*04:04/,
|
||||
)
|
||||
expect(screen.getByText(/Aucune capture cardio synchronisée ;/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Sommeil estimé : mise au lit \+45 min/)).toBeInTheDocument()
|
||||
expect(document.querySelector('.heart-rate-line')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import {
|
|||
type HeartRateTimeline,
|
||||
type HeartRateTimelineEvent,
|
||||
} from '../api/heartRate'
|
||||
import type { MedicationIntake, SleepEntry, SleepEvent } from '../api/sleepDiary'
|
||||
import {
|
||||
hourlyTimelineTicks,
|
||||
timelineInterval,
|
||||
timelinePosition,
|
||||
} from '../routes/sleepTimelineGeometry'
|
||||
import { heartRateVariations } from './heartRateVariation'
|
||||
|
||||
const WIDTH = 1000
|
||||
|
|
@ -76,7 +82,69 @@ function phaseLabel(value: string, french: boolean): string {
|
|||
return labels[value]?.[french ? 0 : 1] ?? value
|
||||
}
|
||||
|
||||
export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
||||
function clock(timestamp: string | number): string {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
}
|
||||
|
||||
function doseLabel(intake: MedicationIntake): string {
|
||||
if (intake.dose_value === null) return intake.medication_name
|
||||
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : ' ×1'
|
||||
return `${intake.medication_name} · ${intake.dose_value} ${intake.dose_unit ?? ''}${quantity}`.trim()
|
||||
}
|
||||
|
||||
interface SleepRange {
|
||||
start: number
|
||||
end: number
|
||||
estimated: boolean
|
||||
}
|
||||
|
||||
function sleepRanges(entry: SleepEntry): SleepRange[] {
|
||||
// CONTRACT: recorded Sleep intervals always outrank visualization-only
|
||||
// estimates. The fallback never mutates the entry or claims a medical
|
||||
// inference; incomplete bounds deliberately produce no range.
|
||||
const factual = entry.events
|
||||
.filter((event) => event.type === 'sleep' && event.end_at !== null)
|
||||
.map((event) => ({
|
||||
start: Date.parse(event.start_at),
|
||||
end: Date.parse(event.end_at as string),
|
||||
estimated: false,
|
||||
}))
|
||||
.filter((range) => Number.isFinite(range.start) && range.end > range.start)
|
||||
if (factual.length > 0) return factual
|
||||
|
||||
const bedTime = entry.events.find((event) => event.type === 'bed_time')
|
||||
const finalGetUp = entry.events.find((event) => event.type === 'final_get_up')
|
||||
if (!bedTime || !finalGetUp) return []
|
||||
const start = Date.parse(bedTime.start_at) + 45 * 60 * 1000
|
||||
const end = Date.parse(finalGetUp.start_at) - 10 * 60 * 1000
|
||||
return Number.isFinite(start) && Number.isFinite(end) && end > start
|
||||
? [{ start, end, estimated: true }]
|
||||
: []
|
||||
}
|
||||
|
||||
function markerTitle(event: SleepEvent, french: boolean): string {
|
||||
const timelineEvent: HeartRateTimelineEvent = {
|
||||
type: event.type,
|
||||
at: event.start_at,
|
||||
end_at: event.end_at,
|
||||
label: null,
|
||||
entry_id: null,
|
||||
exercise_id: null,
|
||||
}
|
||||
return `${eventLabel(timelineEvent, french)} · ${clock(event.start_at)}`
|
||||
}
|
||||
|
||||
export function HeartRateTimelinePanel({
|
||||
contextId,
|
||||
sleepEntry,
|
||||
}: {
|
||||
contextId: string
|
||||
sleepEntry?: SleepEntry
|
||||
}) {
|
||||
const french = !document.documentElement.lang.toLowerCase().startsWith('en')
|
||||
const [timeline, setTimeline] = useState<HeartRateTimeline | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
|
@ -112,6 +180,8 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
|||
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
||||
}, [timeline])
|
||||
|
||||
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
|
||||
|
||||
if (failed) {
|
||||
return <section className="heart-rate-panel" role="alert">
|
||||
{french ? 'Données cardio momentanément indisponibles.' : 'Heart-rate data is temporarily unavailable.'}
|
||||
|
|
@ -122,7 +192,7 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
|||
{french ? 'Chargement de la courbe cardio…' : 'Loading heart-rate timeline…'}
|
||||
</section>
|
||||
}
|
||||
if (!timeline.available || !timeline.capture || !calculated) {
|
||||
if ((!timeline.available || !timeline.capture || !calculated) && !sleepEntry) {
|
||||
return <section className="heart-rate-panel">
|
||||
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
|
||||
<p className="analysis-empty">
|
||||
|
|
@ -132,20 +202,53 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
|||
}
|
||||
|
||||
const capture = timeline.capture
|
||||
const start = Date.parse(capture.started_at)
|
||||
const end = Date.parse(capture.ended_at)
|
||||
const duration = Math.max(1, end - start)
|
||||
const bpmFloor = Math.max(0, calculated.minimum - 8)
|
||||
const bpmCeiling = calculated.maximum + 8
|
||||
const sleepEvents = sleepEntry?.events ?? []
|
||||
const sleepIntakes = sleepEntry?.intakes ?? []
|
||||
const bedTime = sleepEvents.find((event) => event.type === 'bed_time')
|
||||
const finalGetUp = sleepEvents.find((event) => event.type === 'final_get_up')
|
||||
const finalGetUpTime = finalGetUp ? Date.parse(finalGetUp.start_at) : null
|
||||
const visibleSleepIntakes = sleepIntakes.filter((intake) => {
|
||||
const intakeTime = Date.parse(intake.taken_at)
|
||||
return Number.isFinite(intakeTime) &&
|
||||
(finalGetUpTime === null || !Number.isFinite(finalGetUpTime) || intakeTime <= finalGetUpTime)
|
||||
})
|
||||
const boundaryTimes = bedTime && finalGetUp
|
||||
? [
|
||||
Date.parse(bedTime.start_at),
|
||||
Date.parse(finalGetUp.start_at),
|
||||
...visibleSleepIntakes.map((intake) => Date.parse(intake.taken_at)),
|
||||
...(capture ? [Date.parse(capture.started_at), Date.parse(capture.ended_at)] : []),
|
||||
]
|
||||
: [
|
||||
...(capture ? [Date.parse(capture.started_at), Date.parse(capture.ended_at)] : []),
|
||||
...sleepEvents.flatMap((event) => [
|
||||
Date.parse(event.start_at),
|
||||
...(event.end_at ? [Date.parse(event.end_at)] : []),
|
||||
]),
|
||||
...visibleSleepIntakes.map((intake) => Date.parse(intake.taken_at)),
|
||||
]
|
||||
// CONTRACT: a complete night is bounded by its factual bedtime/final get-up,
|
||||
// while pre-bed intakes and the measured capture may widen those bounds.
|
||||
// Daytime diary facts must not compress the nocturnal HR comparison.
|
||||
const factualTimes = boundaryTimes.filter(Number.isFinite)
|
||||
const start = factualTimes.length > 0 ? Math.min(...factualTimes) : 0
|
||||
const end = factualTimes.length > 0 ? Math.max(...factualTimes) : start + 1
|
||||
const bpmFloor = Math.max(0, (calculated?.minimum ?? 60) - 8)
|
||||
const bpmCeiling = (calculated?.maximum ?? 100) + 8
|
||||
const bpmSpan = Math.max(1, bpmCeiling - bpmFloor)
|
||||
const x = (timestamp: string) =>
|
||||
LEFT + ((Date.parse(timestamp) - start) / duration) * (WIDTH - LEFT - RIGHT)
|
||||
LEFT + timelinePosition(timestamp, start, end) * (WIDTH - LEFT - RIGHT)
|
||||
const y = (bpm: number) =>
|
||||
TOP + ((bpmCeiling - bpm) / bpmSpan) * (HEIGHT - TOP - BOTTOM)
|
||||
const points = calculated.display
|
||||
const points = (calculated?.display ?? [])
|
||||
.map((sample) => String(x(sample.observed_at)) + ',' + String(y(sample.bpm)))
|
||||
.join(' ')
|
||||
const visibleEvents = timeline.events.slice(0, 24)
|
||||
const visibleEvents = sleepEntry ? [] : timeline.events.slice(0, 24)
|
||||
const hourlyTicks = hourlyTimelineTicks(start, end)
|
||||
const visibleSleepEvents = sleepEvents.filter((event) => {
|
||||
const eventTime = Date.parse(event.start_at)
|
||||
return Number.isFinite(eventTime) && eventTime >= start && eventTime <= end
|
||||
})
|
||||
|
||||
return <section className="heart-rate-panel" data-testid="heart-rate-timeline">
|
||||
<div className="heart-rate-heading">
|
||||
|
|
@ -153,17 +256,54 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
|||
<p className="eyebrow">{french ? 'DONNÉES MESURÉES' : 'MEASURED DATA'}</p>
|
||||
<h3>{french ? 'Rythme cardiaque' : 'Heart rate'}</h3>
|
||||
</div>
|
||||
<dl className="heart-rate-facts">
|
||||
{calculated && <dl className="heart-rate-facts">
|
||||
<div><dt>Min</dt><dd>{calculated.minimum} BPM</dd></div>
|
||||
<div><dt>{french ? 'Moy.' : 'Avg.'}</dt><dd>{calculated.average.toFixed(1)} BPM</dd></div>
|
||||
<div><dt>Max</dt><dd>{calculated.maximum} BPM</dd></div>
|
||||
<div><dt>RR</dt><dd>{calculated.rrCount}</dd></div>
|
||||
</dl>
|
||||
</dl>}
|
||||
</div>
|
||||
|
||||
<figure className="heart-rate-chart">
|
||||
<svg viewBox={'0 0 ' + WIDTH + ' ' + HEIGHT} role="img"
|
||||
aria-label={french ? 'Courbe du rythme cardiaque mesuré' : 'Measured heart-rate curve'}>
|
||||
{sleep.map((range, index) => {
|
||||
const interval = timelineInterval(range.start, range.end, start, end)
|
||||
const rangeX = LEFT + interval.left * (WIDTH - LEFT - RIGHT)
|
||||
const width = Math.max(1, interval.width * (WIDTH - LEFT - RIGHT))
|
||||
const title = range.estimated
|
||||
? (french ? 'Sommeil estimé (repère visuel, non médical)' : 'Estimated sleep (visual guide, not medical)')
|
||||
: (french ? 'Sommeil déclaré' : 'Declared sleep')
|
||||
return <rect key={`sleep-${index}`}
|
||||
className={`heart-rate-sleep-band${range.estimated ? ' heart-rate-sleep-estimated' : ''}`}
|
||||
data-testid={range.estimated ? 'heart-rate-sleep-estimated' : 'heart-rate-sleep-factual'}
|
||||
tabIndex={0} role="img"
|
||||
aria-label={`${title} · ${clock(range.start)}–${clock(range.end)}`}
|
||||
x={rangeX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM}>
|
||||
<title>{`${title} · ${clock(range.start)}–${clock(range.end)}`}</title>
|
||||
</rect>
|
||||
})}
|
||||
{visibleSleepEvents.filter((event) => event.type === 'long_awake' && event.end_at).map((event) => {
|
||||
const interval = timelineInterval(event.start_at, event.end_at as string, start, end)
|
||||
const title = markerTitle(event, french)
|
||||
return <rect key={event.event_id} className="heart-rate-awake-band"
|
||||
data-testid="heart-rate-awake-band"
|
||||
tabIndex={0} role="img" aria-label={title}
|
||||
x={LEFT + interval.left * (WIDTH - LEFT - RIGHT)} y={TOP}
|
||||
width={Math.max(1, interval.width * (WIDTH - LEFT - RIGHT))}
|
||||
height={HEIGHT - TOP - BOTTOM}>
|
||||
<title>{title}</title>
|
||||
</rect>
|
||||
})}
|
||||
{hourlyTicks.map((tick) => {
|
||||
const tickX = LEFT + tick.position * (WIDTH - LEFT - RIGHT)
|
||||
return <g key={tick.timestamp} data-testid="heart-rate-hour-tick">
|
||||
<line className="heart-rate-hour-grid" x1={tickX} y1={TOP}
|
||||
x2={tickX} y2={HEIGHT - BOTTOM} />
|
||||
<text className="chart-axis-label heart-rate-hour-label" textAnchor="middle"
|
||||
x={tickX} y={HEIGHT - 10}>{clock(tick.timestamp)}</text>
|
||||
</g>
|
||||
})}
|
||||
{timeline.guidance?.phases.map((phase) => {
|
||||
if (!phase.target) return null
|
||||
const phaseX = x(phase.started_at)
|
||||
|
|
@ -173,13 +313,27 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
|||
return <rect key={phase.phase_id} className="heart-rate-target"
|
||||
x={phaseX} y={targetTop} width={phaseWidth} height={targetHeight} />
|
||||
})}
|
||||
{timeline.events.filter((event) => event.end_at).map((event, index) => {
|
||||
{!sleepEntry && timeline.events.filter((event) => event.end_at).map((event, index) => {
|
||||
const eventX = x(event.at)
|
||||
const width = Math.max(1, x(event.end_at as string) - eventX)
|
||||
return <rect key={'interval-' + index} className="heart-rate-event-band"
|
||||
x={eventX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM} />
|
||||
})}
|
||||
{calculated.variations.map((variation, index) => {
|
||||
{visibleSleepEvents.filter((event) => event.end_at && event.type !== 'sleep' &&
|
||||
event.type !== 'long_awake').map((event) => {
|
||||
const interval = timelineInterval(event.start_at, event.end_at as string, start, end)
|
||||
const title = markerTitle(event, french)
|
||||
return <rect key={`sleep-interval-${event.event_id}`}
|
||||
className="heart-rate-event-band"
|
||||
data-testid={`heart-rate-sleep-interval-${event.type}`}
|
||||
tabIndex={0} role="img" aria-label={title}
|
||||
x={LEFT + interval.left * (WIDTH - LEFT - RIGHT)} y={TOP}
|
||||
width={Math.max(1, interval.width * (WIDTH - LEFT - RIGHT))}
|
||||
height={HEIGHT - TOP - BOTTOM}>
|
||||
<title>{title}</title>
|
||||
</rect>
|
||||
})}
|
||||
{(calculated?.variations ?? []).map((variation, index) => {
|
||||
const variationX = x(variation.started_at)
|
||||
const width = Math.max(2, x(variation.ended_at) - variationX)
|
||||
const label = variation.direction === 'rise'
|
||||
|
|
@ -193,7 +347,7 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
|||
})}
|
||||
<line className="chart-axis" x1={LEFT} y1={HEIGHT - BOTTOM}
|
||||
x2={WIDTH - RIGHT} y2={HEIGHT - BOTTOM} />
|
||||
<polyline className="heart-rate-line" points={points} fill="none" />
|
||||
{points.length > 0 && <polyline className="heart-rate-line" points={points} fill="none" />}
|
||||
{visibleEvents.map((event, index) => {
|
||||
const eventX = x(event.at)
|
||||
return <g key={'marker-' + index}>
|
||||
|
|
@ -203,31 +357,83 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
|
|||
y={TOP + 12 + (index % 3) * 13}>{eventLabel(event, french)}</text>
|
||||
</g>
|
||||
})}
|
||||
{visibleSleepEvents.filter((event) => event.end_at === null && event.type !== 'sleep')
|
||||
.map((event, index) => {
|
||||
const eventX = x(event.start_at)
|
||||
const title = markerTitle(event, french)
|
||||
return <g key={event.event_id} data-testid={`heart-rate-sleep-event-${event.type}`}
|
||||
tabIndex={0} role="img" aria-label={title}>
|
||||
<line className={`heart-rate-sleep-marker heart-rate-sleep-marker-${event.type}`}
|
||||
x1={eventX} y1={TOP} x2={eventX} y2={HEIGHT - BOTTOM}>
|
||||
<title>{title}</title>
|
||||
</line>
|
||||
<text className="heart-rate-marker-label" textAnchor={eventX > WIDTH - 150 ? 'end' : 'start'}
|
||||
x={eventX + (eventX > WIDTH - 150 ? -4 : 4)} y={TOP + 12 + (index % 3) * 13}>
|
||||
{eventLabel({ type: event.type } as HeartRateTimelineEvent, french)}
|
||||
<title>{title}</title>
|
||||
</text>
|
||||
</g>
|
||||
})}
|
||||
{visibleSleepIntakes.map((intake, index) => {
|
||||
const intakeX = x(intake.taken_at)
|
||||
const title = `${intake.medication_name} · ${clock(intake.taken_at)} · ${
|
||||
intake.dose_value === null ? (french ? 'dose non renseignée' : 'dose not provided') :
|
||||
`${intake.dose_value} ${intake.dose_unit ?? ''} ×${intake.quantity}`
|
||||
}`
|
||||
return <g key={intake.intake_id} data-testid="heart-rate-medication-marker"
|
||||
tabIndex={0} role="img" aria-label={title}>
|
||||
<circle className="heart-rate-medication-marker" cx={intakeX}
|
||||
cy={HEIGHT - BOTTOM - 12 - (index % 2) * 14} r={6}>
|
||||
<title>{title}</title>
|
||||
</circle>
|
||||
<text className="heart-rate-medication-label" textAnchor="middle" x={intakeX}
|
||||
y={HEIGHT - BOTTOM - 9 - (index % 2) * 14}>M<title>{title}</title></text>
|
||||
</g>
|
||||
})}
|
||||
<text className="chart-axis-label" x={LEFT} y={HEIGHT - 10}>
|
||||
{new Date(start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{clock(start)}
|
||||
</text>
|
||||
<text className="chart-axis-label" textAnchor="end" x={WIDTH - RIGHT} y={HEIGHT - 10}>
|
||||
{new Date(end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</text>
|
||||
<text className="chart-axis-label" x={4} y={y(calculated.maximum) + 4}>
|
||||
{calculated.maximum}
|
||||
</text>
|
||||
<text className="chart-axis-label" x={4} y={y(calculated.minimum) + 4}>
|
||||
{calculated.minimum}
|
||||
{clock(end)}
|
||||
</text>
|
||||
{calculated && <>
|
||||
<text className="chart-axis-label" x={4} y={y(calculated.maximum) + 4}>
|
||||
{calculated.maximum}
|
||||
</text>
|
||||
<text className="chart-axis-label" x={4} y={y(calculated.minimum) + 4}>
|
||||
{calculated.minimum}
|
||||
</text>
|
||||
</>}
|
||||
</svg>
|
||||
{capture.samples.length > calculated.display.length &&
|
||||
{capture && calculated && capture.samples.length > calculated.display.length &&
|
||||
<figcaption>
|
||||
{french
|
||||
? 'Projection graphique bornée conservant les minima/maxima ; statistiques calculées sur toutes les mesures.'
|
||||
: 'Bounded chart projection preserves minima/maxima; statistics use every measurement.'}
|
||||
</figcaption>}
|
||||
</figure>
|
||||
<p className="heart-rate-variation-legend">
|
||||
{!calculated && <p className="analysis-empty">
|
||||
{french
|
||||
? 'Aucune capture cardio synchronisée ; les repères Sleep restent affichés sans inventer de mesures.'
|
||||
: 'No synchronized heart-rate capture; Sleep markers remain visible without invented measurements.'}
|
||||
</p>}
|
||||
{sleep.some((range) => range.estimated) && <p className="heart-rate-estimation-note">
|
||||
{french
|
||||
? 'Sommeil estimé : mise au lit +45 min → lever final −10 min. Repère visuel non médical ; les données Sleep ne sont pas modifiées.'
|
||||
: 'Estimated sleep: bedtime +45 min → final get-up −10 min. Non-medical visual guide; Sleep data is unchanged.'}
|
||||
</p>}
|
||||
{calculated && <p className="heart-rate-variation-legend">
|
||||
{french
|
||||
? 'Rouge : hausse notable · pêche : baisse notable. Variations relatives mesurées, sans inférence sur le sommeil ni leur cause.'
|
||||
: 'Red: notable rise · peach: notable fall. Relative measured variations, with no sleep-state or cause inference.'}
|
||||
</p>
|
||||
</p>}
|
||||
|
||||
{visibleSleepIntakes.length > 0 && <ul className="heart-rate-overlay-list" aria-label={french ? 'Prises médicamenteuses' : 'Medication intakes'}>
|
||||
{visibleSleepIntakes.map((intake) => <li key={intake.intake_id}>
|
||||
<time dateTime={intake.taken_at}>{clock(intake.taken_at)}</time>
|
||||
{' · '}{doseLabel(intake)}
|
||||
</li>)}
|
||||
</ul>}
|
||||
|
||||
{timeline.guidance && <div className="heart-rate-phases">
|
||||
<h4>{french ? 'Phases guidées' : 'Guided phases'}</h4>
|
||||
|
|
|
|||
|
|
@ -908,7 +908,10 @@ export function SleepDiaryWorkspace({
|
|||
|
||||
{selectedEntry && (
|
||||
<div className="analysis-wide">
|
||||
<HeartRateTimelinePanel contextId={selectedEntry.entry_id} />
|
||||
<HeartRateTimelinePanel
|
||||
contextId={selectedEntry.entry_id}
|
||||
sleepEntry={selectedEntry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||