fix(web): align sleep agenda and isolate active nights
This commit is contained in:
parent
9652b35189
commit
e6e5e76f68
15 changed files with 777 additions and 167 deletions
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
## Unreleased — 0.1.4
|
||||
|
||||
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
||||
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
||||
the previous entry immediately, adopts only an exact-date result and rejects
|
||||
stale out-of-order reads; an absent night remains unpersisted and empty until
|
||||
the first real edit. The editor presents local completion as Draft,
|
||||
Day validated or Modified without exposing synchronization transport state.
|
||||
- Fully localized the French Sleep Diary PDF without changing its canonical
|
||||
data source or event semantics. Column headings, timeline labels, legend,
|
||||
factual summary, draft warning, dates and plurals are now French, accented
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ parallel implementation of its rules.
|
|||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and fully localized FR/EN local vector PDF; the Web Agenda, Chronology and factual summary adopt the latest persisted revision without reload, medication choices expose dose/unit, and real-Firefox mutation/reload coverage plus a section-local error boundary protect capture |
|
||||
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and fully localized FR/EN local vector PDF; the Web Agenda, Chronology and factual summary adopt the latest persisted revision without reload, use one exact 18:00-to-18:00 projection and isolate all visible data to the active night despite out-of-order reads; medication choices expose dose/unit, while real-Firefox mutation/date-switch/reload coverage plus a section-local error boundary protect capture |
|
||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||
|
|
|
|||
BIN
docs/reviews/evidence/sleep-diary-alignment-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-alignment-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/reviews/evidence/sleep-diary-empty-night-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-empty-night-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 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 17914 >>
|
||||
<< /Length 17659 >>
|
||||
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
|
||||
|
|
@ -124,21 +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.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.75 g 216.17 469.00 83.33 18.00 re f 0 G 216.17 469.00 83.33 18.00 re S
|
||||
0 g BT /F1 5 Tf 218.2 475.0 Td (SOMMEIL) Tj ET
|
||||
0.5 g 299.50 469.00 15.63 18.00 re f 0 G 299.50 469.00 15.63 18.00 re S
|
||||
0.75 g 315.13 469.00 67.71 18.00 re f 0 G 315.13 469.00 67.71 18.00 re S
|
||||
0 g BT /F1 5 Tf 317.1 475.0 Td (SOMMEIL) Tj ET
|
||||
0.87 g 382.83 469.00 3.47 18.00 re f 0 G 382.83 469.00 3.47 18.00 re S
|
||||
0 g BT /F1 8 Tf 388.0 473.0 Td (^) Tj ET
|
||||
0.93 g 528.67 469.00 12.15 18.00 re f 0 G 528.67 469.00 12.15 18.00 re S
|
||||
0 g BT /F1 8 Tf 549.5 473.0 Td (S) 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 (Moy) Tj ET
|
||||
0 g BT /F1 7 Tf 696.0 482.0 Td (TB) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 491.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 482.0 Td () 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 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
|
||||
|
|
@ -367,9 +363,9 @@ stream
|
|||
0 g BT /F1 4 Tf 730.0 273.0 Td () Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 264.0 Td () Tj ET
|
||||
0 g BT /F1 9 Tf 30.0 240.0 Td (OBSERVATIONS) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 226.0 Td (7 nuits \267 Sommeil d\351clar\351 : 50 h 30 min \267 Longs r\351veils : 7 \267 Siestes : 6 \267 Somnolences : 6 \267 Prises de m\351dicaments : 2) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 226.0 Td (7 nuits \267 Sommeil d\351clar\351 : 48 h 15 min \267 Longs r\351veils : 6 \267 Siestes : 5 \267 Somnolences : 6 \267 Prises de m\351dicaments : 3) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 216.0 Td (AVERTISSEMENT : 7 jours non valid\351s dans cet export.) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 204.0 Td (19/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 204.0 Td (19/09/2026 : Nuit de contr\364le g\351om\351trique.) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 194.0 Td (18/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 184.0 Td (17/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET
|
||||
0 g BT /F1 6 Tf 30.0 28.0 Td (L\351gende : v Mise au lit ; ^ Lever ; ^ Lever nocturne ; S Somnolence ; M Prise de m\351dicament.) Tj ET
|
||||
|
|
@ -387,5 +383,5 @@ xref
|
|||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
18314
|
||||
18059
|
||||
%%EOF
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 148 KiB |
|
|
@ -0,0 +1,32 @@
|
|||
# Sleep Diary active-night and Agenda geometry evidence
|
||||
|
||||
Date: 2026-09-21
|
||||
|
||||
Branch: `trainlog-0.1.4-sleep-diary-v1`
|
||||
|
||||
## Scope
|
||||
|
||||
The Web Sleep Diary uses a single timestamp projection from 18:00 on the
|
||||
selected start date to 18:00 on the following date. Hour ticks, interval edges,
|
||||
point events and grouped medication markers use that same rendered timeline
|
||||
box. The editor clears date-dependent state immediately when the selected
|
||||
night changes and applies an asynchronous diary response only while its request
|
||||
identity and requested date still match the active night.
|
||||
|
||||
The local editor retains user-facing Draft, Day validated and Modified states.
|
||||
Synchronization transport state remains available to the existing backend
|
||||
contracts but is not presented as a per-night action or status.
|
||||
|
||||
## Retained Firefox evidence
|
||||
|
||||
- [Populated aligned night](evidence/sleep-diary-alignment-firefox.png): a
|
||||
synthetic night with events at 22:30, 03:30, 04:45 and 15:30. Browser
|
||||
assertions compare the rendered marker/interval coordinates with the shared
|
||||
axis at a maximum tolerance of one pixel.
|
||||
- [Empty selected night](evidence/sleep-diary-empty-night-firefox.png): after
|
||||
changing dates without reload, the Agenda, chronology, appreciations,
|
||||
medication intakes, notes and factual summary contain no data from the
|
||||
preceding night. No empty entry is persisted by viewing the date.
|
||||
|
||||
The same scenario returns to the populated night without reload and verifies
|
||||
that its persisted events reappear.
|
||||
|
|
@ -253,11 +253,15 @@ Sleep Diary Web tests cover canonical snapshot adoption after every successful
|
|||
autosave, ordered revision chaining, rejection of stale asynchronous reads,
|
||||
and live Agenda updates for bedtime, final get-up, split sleep, time in bed,
|
||||
long awakenings, naps, sleepiness and grouped medication intakes. They also
|
||||
cover localized dose/unit labels, same-name medications with different usual
|
||||
doses and editable intake-dose prefill. `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, then reloads and compares the persisted Agenda.
|
||||
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.
|
||||
`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
|
||||
ticks, point markers and intervals within one rendered pixel, switches to an
|
||||
empty night and back, then reloads and compares the persisted Agenda.
|
||||
The PDF suite separately asserts complete French and English vocabularies,
|
||||
accent-preserving WinAnsi text, human-readable duration and plural forms,
|
||||
localized dates, medication dose snapshots, pagination and the exact ordered
|
||||
|
|
|
|||
|
|
@ -292,6 +292,180 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
driver.execute_script("return document.documentElement.scrollWidth"),
|
||||
driver.execute_script("return document.documentElement.clientWidth"),
|
||||
)
|
||||
|
||||
def stable_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4()}"
|
||||
|
||||
def post_entries(entries: list[dict]):
|
||||
return driver.execute_async_script(
|
||||
"""
|
||||
const entries=arguments[0], done=arguments[arguments.length-1];
|
||||
(async () => {
|
||||
const status = await fetch('/api/v1/sync/status');
|
||||
const token = status.headers.get('X-Trainlog-CSRF-Token');
|
||||
const responses = [];
|
||||
for (const entry of entries) {
|
||||
const response = await fetch('/api/v1/sleep-diary', {
|
||||
method: 'POST',
|
||||
headers: {'Accept':'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':token},
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
responses.push({status: response.status, body: await response.json()});
|
||||
}
|
||||
done(responses);
|
||||
})().catch(error => done([{status:0, body:String(error)}]));
|
||||
""",
|
||||
entries,
|
||||
)
|
||||
|
||||
alignment_medication_id = Select(
|
||||
driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-intake-medication']"
|
||||
)
|
||||
).options[1].get_attribute("value")
|
||||
alignment_entry = {
|
||||
"entry_id": "",
|
||||
"expected_revision": None,
|
||||
"night_start_date": "2026-09-19",
|
||||
"night_end_date": "2026-09-20",
|
||||
"created_at": "2026-09-19T18:00:00+02:00",
|
||||
"updated_at": "2026-09-20T16:00:00+02:00",
|
||||
"sleep_quality": "B",
|
||||
"wake_quality": "TB",
|
||||
"day_form": "Moy",
|
||||
"treatment_and_notes": "Nuit de contrôle géométrique.",
|
||||
"events": [
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "bed_time",
|
||||
"start_at": "2026-09-19T22:30:00+02:00",
|
||||
"end_at": None,
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "sleep",
|
||||
"start_at": "2026-09-19T22:30:00+02:00",
|
||||
"end_at": "2026-09-20T03:30:00+02:00",
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "final_get_up",
|
||||
"start_at": "2026-09-20T04:45:00+02:00",
|
||||
"end_at": None,
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "daytime_sleepiness",
|
||||
"start_at": "2026-09-20T15:30:00+02:00",
|
||||
"end_at": None,
|
||||
},
|
||||
],
|
||||
"intakes": [
|
||||
{
|
||||
"intake_id": stable_id("mdi"),
|
||||
"medication_id": alignment_medication_id,
|
||||
"medication_name": "venlafaxine",
|
||||
"taken_at": "2026-09-19T22:30:00+02:00",
|
||||
"dose_value": 75,
|
||||
"dose_unit": "mg",
|
||||
"note": "",
|
||||
"created_at": "2026-09-19T22:30:00+02:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
responses = post_entries([alignment_entry])
|
||||
self.assertEqual([200], [item["status"] for item in responses])
|
||||
set_value("sleep-night-start", "2026-09-19")
|
||||
wait.until(
|
||||
lambda current: current.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-event-bed_time']"
|
||||
)
|
||||
)
|
||||
geometry = driver.execute_script(
|
||||
"""
|
||||
const axis=document.querySelector('.sleep-hour-axis').getBoundingClientRect();
|
||||
const box=id => document
|
||||
.querySelector(`[data-testid='${id}']`)
|
||||
.getBoundingClientRect();
|
||||
const point=id => {
|
||||
const value=document
|
||||
.querySelector(`[data-testid='${id}'] > span`)
|
||||
.getBoundingClientRect();
|
||||
return value.left + value.width / 2;
|
||||
};
|
||||
const sleep=box('sleep-agenda-event-sleep');
|
||||
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||
.map(value => {
|
||||
const tick=value.getBoundingClientRect();
|
||||
return {
|
||||
label:value.textContent,
|
||||
center:tick.left + tick.width / 2,
|
||||
};
|
||||
});
|
||||
return {
|
||||
left:axis.left,
|
||||
width:axis.width,
|
||||
bed:point('sleep-agenda-event-bed_time'),
|
||||
medication:point('sleep-agenda-medication'),
|
||||
sleepLeft:sleep.left,
|
||||
sleepRight:sleep.right,
|
||||
getUp:point('sleep-agenda-event-final_get_up'),
|
||||
sleepiness:point('sleep-agenda-event-daytime_sleepiness'),
|
||||
ticks,
|
||||
};
|
||||
"""
|
||||
)
|
||||
expected_positions = {
|
||||
"bed": 4.5 / 24,
|
||||
"medication": 4.5 / 24,
|
||||
"sleepLeft": 4.5 / 24,
|
||||
"sleepRight": 9.5 / 24,
|
||||
"getUp": 10.75 / 24,
|
||||
"sleepiness": 21.5 / 24,
|
||||
}
|
||||
for key, fraction in expected_positions.items():
|
||||
expected_x = geometry["left"] + geometry["width"] * fraction
|
||||
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,
|
||||
)
|
||||
self.assertTrue(
|
||||
driver.save_screenshot(
|
||||
str(EVIDENCE / "sleep-diary-alignment-firefox.png")
|
||||
)
|
||||
)
|
||||
|
||||
set_value("sleep-night-start", "2026-09-21")
|
||||
wait.until(
|
||||
lambda current: "Aucune donnée pour cette nuit."
|
||||
in current.page_source
|
||||
)
|
||||
self.assertFalse(
|
||||
driver.find_elements(
|
||||
By.CSS_SELECTOR,
|
||||
"[data-testid='sleep-timeline'] [data-testid^='sleep-event-']",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
driver.find_elements(By.CSS_SELECTOR, "[data-testid='sleep-intake-row']")
|
||||
)
|
||||
self.assertTrue(
|
||||
driver.save_screenshot(
|
||||
str(EVIDENCE / "sleep-diary-empty-night-firefox.png")
|
||||
)
|
||||
)
|
||||
set_value("sleep-night-start", "2026-09-19")
|
||||
wait.until(
|
||||
lambda current: current.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-event-bed_time']"
|
||||
)
|
||||
)
|
||||
bidi = websocket.create_connection(
|
||||
driver.capabilities["webSocketUrl"], suppress_origin=True
|
||||
)
|
||||
|
|
@ -342,11 +516,8 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
)
|
||||
)
|
||||
|
||||
def stable_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4()}"
|
||||
|
||||
extra_entries = []
|
||||
for day in range(14, 20):
|
||||
for day in range(14, 19):
|
||||
start_date = f"2026-09-{day:02d}"
|
||||
end_date = f"2026-09-{day + 1:02d}"
|
||||
|
||||
|
|
@ -419,27 +590,8 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
"intakes": [],
|
||||
}
|
||||
)
|
||||
responses = driver.execute_async_script(
|
||||
"""
|
||||
const entries=arguments[0], done=arguments[arguments.length-1];
|
||||
(async () => {
|
||||
const status = await fetch('/api/v1/sync/status');
|
||||
const token = status.headers.get('X-Trainlog-CSRF-Token');
|
||||
const responses = [];
|
||||
for (const entry of entries) {
|
||||
const response = await fetch('/api/v1/sleep-diary', {
|
||||
method: 'POST',
|
||||
headers: {'Accept':'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':token},
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
responses.push({status: response.status, body: await response.json()});
|
||||
}
|
||||
done(responses);
|
||||
})().catch(error => done([{status:0, body:String(error)}]));
|
||||
""",
|
||||
extra_entries,
|
||||
)
|
||||
self.assertEqual([200] * 6, [item["status"] for item in responses])
|
||||
responses = post_entries(extra_entries)
|
||||
self.assertEqual([200] * 5, [item["status"] for item in responses])
|
||||
driver.refresh()
|
||||
wait.until(
|
||||
lambda current: len(
|
||||
|
|
@ -447,7 +599,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
|
||||
)
|
||||
)
|
||||
== 7
|
||||
== 1
|
||||
)
|
||||
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
||||
download_path = EVIDENCE / "trainlog-sleep-diary.pdf"
|
||||
|
|
|
|||
|
|
@ -116,6 +116,58 @@ const persistedEntry = (
|
|||
intakes: input.intakes.map((intake) => ({ ...intake })),
|
||||
});
|
||||
|
||||
const dayAEntry = (): SleepEntry => ({
|
||||
entry_id: "sd_00000000-0000-4000-8000-000000000020",
|
||||
revision_id: "sdr_00000000-0000-4000-8000-000000000020",
|
||||
night_start_date: "2026-09-20",
|
||||
night_end_date: "2026-09-21",
|
||||
created_at: "2026-09-20T18:00:00+02:00",
|
||||
updated_at: "2026-09-21T16:00:00+02:00",
|
||||
sleep_quality: "B",
|
||||
wake_quality: "TB",
|
||||
day_form: "Moy",
|
||||
treatment_and_notes: "Observation du jour A",
|
||||
publication_status: "ready",
|
||||
events: [
|
||||
{
|
||||
event_id: "sle_bed",
|
||||
type: "bed_time",
|
||||
start_at: "2026-09-20T22:30:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
{
|
||||
event_id: "sle_sleep",
|
||||
type: "sleep",
|
||||
start_at: "2026-09-20T22:30:00+02:00",
|
||||
end_at: "2026-09-21T03:30:00+02:00",
|
||||
},
|
||||
{
|
||||
event_id: "sle_up",
|
||||
type: "final_get_up",
|
||||
start_at: "2026-09-21T04:45:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
{
|
||||
event_id: "sle_sleepiness",
|
||||
type: "daytime_sleepiness",
|
||||
start_at: "2026-09-21T15:30:00+02:00",
|
||||
end_at: null,
|
||||
},
|
||||
],
|
||||
intakes: [
|
||||
{
|
||||
intake_id: "mdi_day_a",
|
||||
medication_id: medication.medication_id,
|
||||
medication_name: medication.name,
|
||||
taken_at: "2026-09-20T22:30:00+02:00",
|
||||
dose_value: 5,
|
||||
dose_unit: "mg",
|
||||
note: "",
|
||||
created_at: "2026-09-20T22:30:00+02:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("SleepDiaryWorkspace", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -503,4 +555,156 @@ describe("SleepDiaryWorkspace", () => {
|
|||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sleep-agenda-event-sleep")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("aligns ticks, events, intervals, and medication markers on one 18:00 axis", async () => {
|
||||
api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()]));
|
||||
api.fetchSleepMedications.mockResolvedValue([medication]);
|
||||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
|
||||
const bedtime = await screen.findByTestId("sleep-agenda-event-bed_time");
|
||||
const sleep = screen.getByTestId("sleep-agenda-event-sleep");
|
||||
const getUp = screen.getByTestId("sleep-agenda-event-final_get_up");
|
||||
const sleepiness = screen.getByTestId(
|
||||
"sleep-agenda-event-daytime_sleepiness",
|
||||
);
|
||||
const intake = screen.getByTestId("sleep-agenda-medication");
|
||||
expect(bedtime).toHaveStyle({ left: "18.75%" });
|
||||
expect(sleep).toHaveStyle({ left: "18.75%" });
|
||||
expect(Number.parseFloat(sleep.style.width)).toBeCloseTo((5 / 24) * 100, 9);
|
||||
expect(Number.parseFloat(getUp.style.left)).toBeCloseTo(
|
||||
(10.75 / 24) * 100,
|
||||
9,
|
||||
);
|
||||
expect(Number.parseFloat(sleepiness.style.left)).toBeCloseTo(
|
||||
(21.5 / 24) * 100,
|
||||
9,
|
||||
);
|
||||
expect(intake).toHaveStyle({ left: "18.75%" });
|
||||
expect(document.querySelectorAll(".sleep-hour-axis > span")).toHaveLength(
|
||||
25,
|
||||
);
|
||||
});
|
||||
|
||||
it("isolates an empty active night and restores the previous night without reload", async () => {
|
||||
api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()]));
|
||||
api.fetchSleepMedications.mockResolvedValue([medication]);
|
||||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("sleep-agenda-event-bed_time"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
||||
"Journée validée",
|
||||
);
|
||||
expect(screen.queryByText("Prête à synchroniser")).toBeNull();
|
||||
expect(
|
||||
screen.getByLabelText("Traitement et remarques particulières"),
|
||||
).toHaveValue("Observation du jour A");
|
||||
|
||||
fireEvent.change(screen.getByTestId("sleep-night-start"), {
|
||||
target: { value: "2026-09-21" },
|
||||
});
|
||||
expect(screen.getByTestId("sleep-night-end")).toHaveValue("2026-09-22");
|
||||
expect(screen.queryByTestId("sleep-event-bed_time")).toBeNull();
|
||||
expect(screen.queryByTestId("sleep-intake-row")).toBeNull();
|
||||
expect(screen.getByTestId("sleep-timeline")).toHaveTextContent(
|
||||
"Aucun événement enregistré pour cette nuit.",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Aucune donnée pour cette nuit."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("Traitement et remarques particulières"),
|
||||
).toHaveValue("");
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole("group", { name: "Qualité du sommeil" }),
|
||||
).queryByRole("button", { pressed: true }),
|
||||
).toBeNull();
|
||||
const summary = screen.getByRole("heading", { name: "Synthèse factuelle" })
|
||||
.parentElement as HTMLElement;
|
||||
expect(summary).toHaveTextContent("Sommeil déclaré—");
|
||||
expect(summary).toHaveTextContent("Somnolence—");
|
||||
expect(summary).toHaveTextContent("Prises—");
|
||||
|
||||
await waitFor(() => expect(api.fetchSleepDiary).toHaveBeenCalledTimes(2));
|
||||
fireEvent.change(screen.getByTestId("sleep-night-start"), {
|
||||
target: { value: "2026-09-20" },
|
||||
});
|
||||
expect(
|
||||
await screen.findByTestId("sleep-event-bed_time"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sleep-intake-row")).toHaveTextContent("TestMed");
|
||||
expect(
|
||||
screen.getByLabelText("Traitement et remarques particulières"),
|
||||
).toHaveValue("Observation du jour A");
|
||||
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
||||
"Journée validée",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a slow previous-night fetch contaminate the active empty night", async () => {
|
||||
let resolveNightA: ((value: SleepSnapshot) => void) | undefined;
|
||||
const slowNightA = new Promise<SleepSnapshot>((resolve) => {
|
||||
resolveNightA = resolve;
|
||||
});
|
||||
api.fetchSleepDiary
|
||||
.mockImplementationOnce(async () => slowNightA)
|
||||
.mockResolvedValue(emptySnapshot);
|
||||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
|
||||
fireEvent.change(screen.getByTestId("sleep-night-start"), {
|
||||
target: { value: "2026-09-21" },
|
||||
});
|
||||
await waitFor(() => expect(api.fetchSleepDiary).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByTestId("sleep-night-start")).toHaveValue("2026-09-21");
|
||||
expect(
|
||||
screen.getByText("Aucune donnée pour cette nuit."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
resolveNightA?.(snapshotWith([dayAEntry()]));
|
||||
await Promise.resolve();
|
||||
expect(screen.getByTestId("sleep-night-start")).toHaveValue("2026-09-21");
|
||||
expect(screen.queryByTestId("sleep-event-bed_time")).toBeNull();
|
||||
expect(screen.queryByTestId("sleep-intake-row")).toBeNull();
|
||||
expect(
|
||||
screen.getByText("Aucune donnée pour cette nuit."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("presents validation as a local completion state without transport wording", async () => {
|
||||
let current: SleepEntry = {
|
||||
...dayAEntry(),
|
||||
publication_status: "draft",
|
||||
};
|
||||
api.fetchSleepDiary.mockImplementation(async () => snapshotWith([current]));
|
||||
api.saveSleepEntry.mockImplementation(async (input: SleepEntryInput) => {
|
||||
current = {
|
||||
...persistedEntry(input, current.entry_id, current.revision_id),
|
||||
publication_status: "draft",
|
||||
};
|
||||
return {
|
||||
entry_id: current.entry_id,
|
||||
revision_id: current.revision_id,
|
||||
};
|
||||
});
|
||||
api.validateSleepEntry.mockImplementation(async () => {
|
||||
current = { ...current, publication_status: "ready" };
|
||||
});
|
||||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
expect(
|
||||
await screen.findByTestId("sleep-publication-status"),
|
||||
).toHaveTextContent("Brouillon");
|
||||
|
||||
fireEvent.click(screen.getByTestId("sleep-validate-day"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
||||
"Journée validée",
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText("Prête à synchroniser")).toBeNull();
|
||||
expect(screen.getByTestId("sleep-save-state")).toHaveTextContent(
|
||||
"Enregistré localement",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import {
|
|||
} from "../report/sleepDiaryPdf";
|
||||
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
||||
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
||||
import {
|
||||
sleepTimelineInterval,
|
||||
sleepTimelinePosition,
|
||||
} from "./sleepTimelineGeometry";
|
||||
|
||||
type Language = "fr" | "en";
|
||||
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
||||
|
|
@ -57,6 +61,7 @@ const copy = {
|
|||
observations: "Observations",
|
||||
summary: "Synthèse factuelle",
|
||||
empty: "Aucune nuit enregistrée.",
|
||||
agendaEmpty: "Aucune donnée pour cette nuit.",
|
||||
preview: "Prévisualiser",
|
||||
export: "Exporter PDF",
|
||||
medications: "Médicaments",
|
||||
|
|
@ -86,9 +91,9 @@ const copy = {
|
|||
notesPlaceholder: "Notes utiles pour cette nuit…",
|
||||
statuses: {
|
||||
draft: "Brouillon",
|
||||
ready: "Prête à synchroniser",
|
||||
synchronized: "Synchronisée",
|
||||
modified: "Modifiée depuis la synchronisation",
|
||||
ready: "Journée validée",
|
||||
synchronized: "Journée validée",
|
||||
modified: "Modifiée",
|
||||
},
|
||||
qualities: ["Très bon", "Bon", "Moyen", "Mauvais", "Très mauvais"],
|
||||
},
|
||||
|
|
@ -116,6 +121,7 @@ const copy = {
|
|||
observations: "Observations",
|
||||
summary: "Factual summary",
|
||||
empty: "No recorded nights.",
|
||||
agendaEmpty: "No data for this night.",
|
||||
preview: "Preview",
|
||||
export: "Export PDF",
|
||||
medications: "Medications",
|
||||
|
|
@ -145,9 +151,9 @@ const copy = {
|
|||
continue: "Continue",
|
||||
statuses: {
|
||||
draft: "Draft",
|
||||
ready: "Ready to sync",
|
||||
synchronized: "Synchronized",
|
||||
modified: "Modified since synchronization",
|
||||
ready: "Day validated",
|
||||
synchronized: "Day validated",
|
||||
modified: "Modified",
|
||||
},
|
||||
qualities: ["Very good", "Good", "Average", "Bad", "Very bad"],
|
||||
},
|
||||
|
|
@ -183,28 +189,21 @@ const atClock = (value: string, startDate: string, endDate: string) => {
|
|||
const offsetRemainder = String(absoluteOffset % 60).padStart(2, "0");
|
||||
return `${date}T${value}:00${sign}${offsetHours}:${offsetRemainder}`;
|
||||
};
|
||||
const offset = (iso: string, startDate: string) => {
|
||||
const start = new Date(`${startDate}T18:00:00`);
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
((new Date(iso).getTime() - start.getTime()) / 86_400_000) * 100,
|
||||
),
|
||||
);
|
||||
};
|
||||
const blank = (): SleepEntryInput => {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
if (now.getHours() < 18) start.setDate(start.getDate() - 1);
|
||||
const end = new Date(start);
|
||||
const nextDate = (startDate: string) => {
|
||||
const end = new Date(`${startDate}T12:00:00`);
|
||||
end.setDate(end.getDate() + 1);
|
||||
const date = (value: Date) => value.toLocaleDateString("en-CA");
|
||||
return end.toLocaleDateString("en-CA");
|
||||
};
|
||||
const blankForNight = (
|
||||
startDate: string,
|
||||
endDate = nextDate(startDate),
|
||||
): SleepEntryInput => {
|
||||
const now = new Date();
|
||||
return {
|
||||
entry_id: "",
|
||||
expected_revision: null,
|
||||
night_start_date: date(start),
|
||||
night_end_date: date(end),
|
||||
night_start_date: startDate,
|
||||
night_end_date: endDate,
|
||||
created_at: sleepTimestamp(now),
|
||||
updated_at: sleepTimestamp(now),
|
||||
sleep_quality: null,
|
||||
|
|
@ -215,6 +214,13 @@ const blank = (): SleepEntryInput => {
|
|||
intakes: [],
|
||||
};
|
||||
};
|
||||
const blank = (): SleepEntryInput => {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
if (now.getHours() < 18) start.setDate(start.getDate() - 1);
|
||||
const date = (value: Date) => value.toLocaleDateString("en-CA");
|
||||
return blankForNight(date(start));
|
||||
};
|
||||
|
||||
function QualityPicker({
|
||||
label,
|
||||
|
|
@ -258,6 +264,7 @@ export function SleepDiaryWorkspace({
|
|||
> | null>(null);
|
||||
const entries = snapshot?.entries ?? [];
|
||||
const [draft, setDraft] = useState<SleepEntryInput>(blank);
|
||||
const [activeNight, setActiveNight] = useState(draft.night_start_date);
|
||||
const [eventType, setEventType] = useState<SleepEventType>("sleep");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -265,9 +272,9 @@ export function SleepDiaryWorkspace({
|
|||
const [eventEnd, setEventEnd] = useState("23:30");
|
||||
const [publicationStatus, setPublicationStatus] =
|
||||
useState<SleepEntry["publication_status"]>("draft");
|
||||
const [saveState, setSaveState] = useState<"saved" | "saving" | "error">(
|
||||
"saved",
|
||||
);
|
||||
const [saveState, setSaveState] = useState<
|
||||
"idle" | "saved" | "saving" | "error"
|
||||
>("idle");
|
||||
const [medications, setMedications] = useState<SleepMedication[]>([]);
|
||||
const [medName, setMedName] = useState("");
|
||||
const [medDose, setMedDose] = useState("");
|
||||
|
|
@ -286,6 +293,8 @@ export function SleepDiaryWorkspace({
|
|||
const mutationSequence = useRef(0);
|
||||
const diaryReadSequence = useRef(0);
|
||||
const medicationReadSequence = useRef(0);
|
||||
const nightSelectionSequence = useRef(0);
|
||||
const activeNightRef = useRef(activeNight);
|
||||
const range = useMemo(() => {
|
||||
if (period === "all") return {};
|
||||
const days = Number(period.slice(0, -1));
|
||||
|
|
@ -312,29 +321,37 @@ export function SleepDiaryWorkspace({
|
|||
return catalog;
|
||||
};
|
||||
const adoptEntry = (entry: SleepEntry) => {
|
||||
activeNightRef.current = entry.night_start_date;
|
||||
setActiveNight(entry.night_start_date);
|
||||
identity.current = {
|
||||
entry_id: entry.entry_id,
|
||||
revision_id: entry.revision_id,
|
||||
};
|
||||
setPublicationStatus(entry.publication_status);
|
||||
setDraft(canonicalInput(entry));
|
||||
setSaveState("saved");
|
||||
};
|
||||
const resetNight = (startDate: string) => {
|
||||
identity.current = null;
|
||||
setDraft(blankForNight(startDate));
|
||||
setPublicationStatus("draft");
|
||||
setSaveState("idle");
|
||||
};
|
||||
const reload = async () => {
|
||||
const requestedNight = activeNightRef.current;
|
||||
const selection = ++nightSelectionSequence.current;
|
||||
try {
|
||||
const [diary] = await Promise.all([readDiary(), readMedications()]);
|
||||
const current = identity.current;
|
||||
const resume = current
|
||||
? diary.entries.find(
|
||||
(entry) =>
|
||||
entry.entry_id === current.entry_id &&
|
||||
entry.revision_id === current.revision_id,
|
||||
)
|
||||
: (diary.entries.find(
|
||||
(entry) =>
|
||||
entry.publication_status === "draft" ||
|
||||
entry.publication_status === "modified",
|
||||
) ?? diary.entries[0]);
|
||||
if (resume) adoptEntry(resume);
|
||||
if (
|
||||
selection !== nightSelectionSequence.current ||
|
||||
requestedNight !== activeNightRef.current
|
||||
)
|
||||
return;
|
||||
const exact = diary.entries.find(
|
||||
(entry) => entry.night_start_date === requestedNight,
|
||||
);
|
||||
if (exact) adoptEntry(exact);
|
||||
else resetNight(requestedNight);
|
||||
} catch {
|
||||
setError("sleep_diary_unavailable");
|
||||
}
|
||||
|
|
@ -343,17 +360,47 @@ export function SleepDiaryWorkspace({
|
|||
void reload();
|
||||
}, [period]);
|
||||
const edit = (entry: SleepEntry) => {
|
||||
identity.current = {
|
||||
entry_id: entry.entry_id,
|
||||
revision_id: entry.revision_id,
|
||||
};
|
||||
++nightSelectionSequence.current;
|
||||
adoptEntry(entry);
|
||||
};
|
||||
const selectNight = (startDate: string) => {
|
||||
const selection = ++nightSelectionSequence.current;
|
||||
++mutationSequence.current;
|
||||
activeNightRef.current = startDate;
|
||||
setActiveNight(startDate);
|
||||
resetNight(startDate);
|
||||
setError("");
|
||||
const cached = snapshot?.entries.find(
|
||||
(entry) => entry.night_start_date === startDate,
|
||||
);
|
||||
if (cached) adoptEntry(cached);
|
||||
void readDiary()
|
||||
.then((diary) => {
|
||||
if (
|
||||
selection !== nightSelectionSequence.current ||
|
||||
startDate !== activeNightRef.current
|
||||
)
|
||||
return;
|
||||
const exact = diary.entries.find(
|
||||
(entry) => entry.night_start_date === startDate,
|
||||
);
|
||||
if (exact) adoptEntry(exact);
|
||||
else resetNight(startDate);
|
||||
})
|
||||
.catch(() => {
|
||||
if (selection === nightSelectionSequence.current)
|
||||
setError("sleep_diary_unavailable");
|
||||
});
|
||||
};
|
||||
const persist = (next: SleepEntryInput) => {
|
||||
++nightSelectionSequence.current;
|
||||
const sequence = ++mutationSequence.current;
|
||||
setSaveState("saving");
|
||||
persistence.current = persistence.current.then(async () => {
|
||||
try {
|
||||
// CONTRACT: a queued edit belongs only to the night on which it was
|
||||
// authored; navigating away must not apply it with another identity.
|
||||
if (activeNightRef.current !== next.night_start_date) return;
|
||||
const current = identity.current;
|
||||
const result = await saveSleepEntry({
|
||||
...next,
|
||||
|
|
@ -361,6 +408,7 @@ export function SleepDiaryWorkspace({
|
|||
expected_revision: current?.revision_id ?? next.expected_revision,
|
||||
updated_at: sleepTimestamp(),
|
||||
});
|
||||
if (activeNightRef.current !== next.night_start_date) return;
|
||||
identity.current = result;
|
||||
const diary = await readDiary();
|
||||
const persisted = diary.entries.find(
|
||||
|
|
@ -375,7 +423,11 @@ export function SleepDiaryWorkspace({
|
|||
setSaveState("saved");
|
||||
}
|
||||
} catch (reason: unknown) {
|
||||
if (sequence !== mutationSequence.current) return;
|
||||
if (
|
||||
sequence !== mutationSequence.current ||
|
||||
activeNightRef.current !== next.night_start_date
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const diary = await readDiary();
|
||||
const current = identity.current;
|
||||
|
|
@ -576,7 +628,7 @@ export function SleepDiaryWorkspace({
|
|||
revision_id: current.revision_id,
|
||||
});
|
||||
identity.current = null;
|
||||
setDraft(blank());
|
||||
resetNight(activeNightRef.current);
|
||||
await reload();
|
||||
} catch (reason) {
|
||||
setError(
|
||||
|
|
@ -602,6 +654,11 @@ export function SleepDiaryWorkspace({
|
|||
index,
|
||||
})),
|
||||
].sort((a, b) => a.at.localeCompare(b.at) || a.kind.localeCompare(b.kind));
|
||||
const activeEntry = entries.find(
|
||||
(entry) => entry.night_start_date === activeNight,
|
||||
);
|
||||
const activeFacts = activeEntry ? sleepEntryFacts(activeEntry) : null;
|
||||
const agendaEntries = activeEntry ? [activeEntry] : [];
|
||||
return (
|
||||
<div className="sleep-workspace" data-testid="sleep-workspace">
|
||||
<article className="analysis-panel analysis-wide">
|
||||
|
|
@ -638,17 +695,26 @@ export function SleepDiaryWorkspace({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<p className="analysis-empty">{t.empty}</p>
|
||||
{agendaEntries.length === 0 ? (
|
||||
<p className="analysis-empty">{t.agendaEmpty}</p>
|
||||
) : (
|
||||
<div className="sleep-agenda-scroll">
|
||||
<div className="sleep-agenda" role="table">
|
||||
<div className="sleep-hours" aria-hidden="true">
|
||||
{Array.from({ length: 25 }, (_, index) => (
|
||||
<span key={index}>{(18 + index) % 24}</span>
|
||||
))}
|
||||
<div className="sleep-axis-row" aria-hidden="true">
|
||||
<span />
|
||||
<span className="sleep-hour-axis">
|
||||
{Array.from({ length: 25 }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{ left: `${(index / 24) * 100}%` }}
|
||||
>
|
||||
{(18 + index) % 24}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
{entries.map((entry) => {
|
||||
{agendaEntries.map((entry) => {
|
||||
const facts = sleepEntryFacts(entry);
|
||||
const groupedIntakes = Object.entries(
|
||||
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
||||
|
|
@ -675,33 +741,46 @@ export function SleepDiaryWorkspace({
|
|||
</strong>
|
||||
<span className="sleep-track">
|
||||
{entry.events.map((event) => {
|
||||
const left = offset(
|
||||
event.start_at,
|
||||
entry.night_start_date,
|
||||
);
|
||||
const width = event.end_at
|
||||
? Math.max(
|
||||
1,
|
||||
offset(event.end_at, entry.night_start_date) -
|
||||
left,
|
||||
const geometry = event.end_at
|
||||
? sleepTimelineInterval(
|
||||
event.start_at,
|
||||
event.end_at,
|
||||
entry.night_start_date,
|
||||
entry.created_at,
|
||||
)
|
||||
: 1;
|
||||
: {
|
||||
left: sleepTimelinePosition(
|
||||
event.start_at,
|
||||
entry.night_start_date,
|
||||
entry.created_at,
|
||||
),
|
||||
width: 0,
|
||||
};
|
||||
return (
|
||||
<i
|
||||
key={event.event_id}
|
||||
className={`sleep-event sleep-${event.type}`}
|
||||
className={`sleep-event sleep-${event.type}${event.end_at ? "" : " sleep-point"}`}
|
||||
data-testid={`sleep-agenda-event-${event.type}`}
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
style={{
|
||||
left: `${geometry.left * 100}%`,
|
||||
width: event.end_at
|
||||
? `${geometry.width * 100}%`
|
||||
: undefined,
|
||||
}}
|
||||
title={`${t[event.type]} ${clock(event.start_at)}${event.end_at ? ` → ${clock(event.end_at)}` : ""}`}
|
||||
>
|
||||
{event.type === "bed_time"
|
||||
? "↓"
|
||||
: event.type === "final_get_up" ||
|
||||
event.type === "night_get_up"
|
||||
? "↑"
|
||||
: event.type === "daytime_sleepiness"
|
||||
? "S"
|
||||
: ""}
|
||||
{!event.end_at && (
|
||||
<span>
|
||||
{event.type === "bed_time"
|
||||
? "↓"
|
||||
: event.type === "final_get_up" ||
|
||||
event.type === "night_get_up"
|
||||
? "↑"
|
||||
: event.type === "daytime_sleepiness"
|
||||
? "S"
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</i>
|
||||
);
|
||||
})}
|
||||
|
|
@ -711,12 +790,13 @@ export function SleepDiaryWorkspace({
|
|||
className="sleep-event sleep-medication"
|
||||
data-testid="sleep-agenda-medication"
|
||||
style={{
|
||||
left: `${offset(takenAt, entry.night_start_date)}%`,
|
||||
width: "1%",
|
||||
left: `${sleepTimelinePosition(takenAt, entry.night_start_date, entry.created_at) * 100}%`,
|
||||
}}
|
||||
title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`}
|
||||
>
|
||||
M{intakes.length > 1 ? ` ×${intakes.length}` : ""}
|
||||
<span>
|
||||
M{intakes.length > 1 ? ` ×${intakes.length}` : ""}
|
||||
</span>
|
||||
</i>
|
||||
))}
|
||||
</span>
|
||||
|
|
@ -818,20 +898,26 @@ export function SleepDiaryWorkspace({
|
|||
<div className="sleep-dates">
|
||||
<input
|
||||
aria-label={`${t.night} — ${t.start}`}
|
||||
data-testid="sleep-night-start"
|
||||
type="date"
|
||||
value={draft.night_start_date}
|
||||
onChange={(event) =>
|
||||
change("night_start_date", event.target.value)
|
||||
}
|
||||
onChange={(event) => selectNight(event.target.value)}
|
||||
/>
|
||||
<span aria-hidden="true">→</span>
|
||||
<input
|
||||
aria-label={`${t.night} — ${t.end}`}
|
||||
data-testid="sleep-night-end"
|
||||
type="date"
|
||||
value={draft.night_end_date}
|
||||
onChange={(event) =>
|
||||
change("night_end_date", event.target.value)
|
||||
}
|
||||
onChange={(event) => {
|
||||
if (identity.current)
|
||||
change("night_end_date", event.target.value);
|
||||
else
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
night_end_date: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -842,17 +928,19 @@ export function SleepDiaryWorkspace({
|
|||
>
|
||||
{t.statuses[publicationStatus]}
|
||||
</strong>
|
||||
<span
|
||||
data-testid="sleep-save-state"
|
||||
className={`sleep-save-state sleep-save-${saveState}`}
|
||||
role="status"
|
||||
>
|
||||
{saveState === "saving"
|
||||
? t.saving
|
||||
: saveState === "saved"
|
||||
? t.saved
|
||||
: error}
|
||||
</span>
|
||||
{saveState !== "idle" && (
|
||||
<span
|
||||
data-testid="sleep-save-state"
|
||||
className={`sleep-save-state sleep-save-${saveState}`}
|
||||
role="status"
|
||||
>
|
||||
{saveState === "saving"
|
||||
? t.saving
|
||||
: saveState === "saved"
|
||||
? t.saved
|
||||
: error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -1353,46 +1441,60 @@ export function SleepDiaryWorkspace({
|
|||
<dt>
|
||||
{language === "fr" ? "Nuits enregistrées" : "Recorded nights"}
|
||||
</dt>
|
||||
<dd>{entries.length}</dd>
|
||||
<dd>{activeEntry ? 1 : 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{language === "fr" ? "Sommeil déclaré" : "Declared sleep"}
|
||||
</dt>
|
||||
<dd>
|
||||
{formatDuration(snapshot?.summary.sleep_duration_seconds ?? 0)}
|
||||
{activeFacts && activeFacts.sleepSeconds > 0
|
||||
? formatDuration(activeFacts.sleepSeconds)
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{language === "fr" ? "Longs réveils" : "Long awakenings"}</dt>
|
||||
<dd>
|
||||
{countAndDuration(
|
||||
snapshot?.summary.long_awake_count ?? 0,
|
||||
snapshot?.summary.long_awake_duration_seconds ?? 0,
|
||||
)}
|
||||
{activeFacts
|
||||
? countAndDuration(
|
||||
activeFacts.longAwakeCount,
|
||||
activeFacts.longAwakeSeconds,
|
||||
)
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{language === "fr" ? "Siestes" : "Naps"}</dt>
|
||||
<dd>
|
||||
{countAndDuration(
|
||||
snapshot?.summary.nap_count ?? 0,
|
||||
snapshot?.summary.nap_duration_seconds ?? 0,
|
||||
)}
|
||||
{activeFacts
|
||||
? countAndDuration(
|
||||
activeFacts.napCount,
|
||||
activeFacts.napSeconds,
|
||||
)
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t.daytime_sleepiness}</dt>
|
||||
<dd>{activeFacts ? activeFacts.sleepinessCount : "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{language === "fr" ? "Prises" : "Intakes"}</dt>
|
||||
<dd>{activeEntry ? activeEntry.intakes.length : "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<article className="analysis-panel">
|
||||
<h2>{t.observations}</h2>
|
||||
{entries
|
||||
.filter((entry) => entry.treatment_and_notes)
|
||||
.map((entry) => (
|
||||
<p key={entry.entry_id}>
|
||||
<strong>{entry.night_start_date}</strong> —{" "}
|
||||
{entry.treatment_and_notes}
|
||||
</p>
|
||||
))}
|
||||
{activeEntry?.treatment_and_notes ? (
|
||||
<p>
|
||||
<strong>{activeEntry.night_start_date}</strong> —{" "}
|
||||
{activeEntry.treatment_and_notes}
|
||||
</p>
|
||||
) : (
|
||||
<p className="analysis-empty">—</p>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
43
web/src/routes/sleepTimelineGeometry.test.ts
Normal file
43
web/src/routes/sleepTimelineGeometry.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
sleepTimelineInterval,
|
||||
sleepTimelinePosition,
|
||||
} from "./sleepTimelineGeometry";
|
||||
|
||||
describe("sleep timeline geometry", () => {
|
||||
it.each([
|
||||
["2026-09-20T18:00:00Z", 0],
|
||||
["2026-09-21T00:00:00Z", 0.25],
|
||||
["2026-09-21T06:00:00Z", 0.5],
|
||||
["2026-09-21T12:00:00Z", 0.75],
|
||||
["2026-09-21T18:00:00Z", 1],
|
||||
["2026-09-20T22:30:00Z", 0.1875],
|
||||
["2026-09-21T04:45:00Z", 0.4479166667],
|
||||
["2026-09-21T15:30:00Z", 0.8958333333],
|
||||
])("projects %s onto the shared 18:00 axis", (timestamp, expected) => {
|
||||
expect(sleepTimelinePosition(timestamp, "2026-09-20")).toBeCloseTo(
|
||||
expected,
|
||||
9,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same axis for interval left and width", () => {
|
||||
const interval = sleepTimelineInterval(
|
||||
"2026-09-20T22:30:00Z",
|
||||
"2026-09-21T03:30:00Z",
|
||||
"2026-09-20",
|
||||
);
|
||||
expect(interval.left).toBeCloseTo(0.1875, 12);
|
||||
expect(interval.width).toBeCloseTo(5 / 24, 12);
|
||||
});
|
||||
|
||||
it("keeps one absolute origin when event offsets differ", () => {
|
||||
expect(
|
||||
sleepTimelinePosition(
|
||||
"2026-10-25T05:00:00+01:00",
|
||||
"2026-10-24",
|
||||
"2026-10-24T18:00:00+02:00",
|
||||
),
|
||||
).toBeCloseTo(0.5, 12);
|
||||
});
|
||||
});
|
||||
37
web/src/routes/sleepTimelineGeometry.ts
Normal file
37
web/src/routes/sleepTimelineGeometry.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export const SLEEP_TIMELINE_DURATION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function sleepTimelinePosition(
|
||||
timestamp: string,
|
||||
nightStartDate: string,
|
||||
timelineOffsetSource = timestamp,
|
||||
): number {
|
||||
const timelineOffset =
|
||||
timelineOffsetSource.match(/(Z|[+-]\d{2}:\d{2})$/)?.[1] ?? "";
|
||||
// CONTRACT: the visual day is exactly 24 elapsed hours beginning at local
|
||||
// 18:00 in the event timestamp's own offset. Ticks and every event consumer
|
||||
// call this projection, so text columns can never affect temporal geometry.
|
||||
const timelineStart = new Date(
|
||||
`${nightStartDate}T18:00:00${timelineOffset}`,
|
||||
).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),
|
||||
);
|
||||
}
|
||||
|
||||
export function sleepTimelineInterval(
|
||||
startAt: string,
|
||||
endAt: string,
|
||||
nightStartDate: string,
|
||||
timelineOffsetSource = startAt,
|
||||
): { left: number; width: number } {
|
||||
const left = sleepTimelinePosition(
|
||||
startAt,
|
||||
nightStartDate,
|
||||
timelineOffsetSource,
|
||||
);
|
||||
const end = sleepTimelinePosition(endAt, nightStartDate, timelineOffsetSource);
|
||||
return { left, width: Math.max(0, end - left) };
|
||||
}
|
||||
|
|
@ -683,14 +683,32 @@ h2 {
|
|||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sleep-hours {
|
||||
.sleep-axis-row {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem repeat(25, 1fr) 25rem;
|
||||
grid-template-columns: 12rem minmax(28rem, 1fr) 25rem;
|
||||
align-items: end;
|
||||
gap: 0.6rem;
|
||||
padding: 0 0.45rem;
|
||||
min-height: 1.2rem;
|
||||
color: var(--subtext-0);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.sleep-hours::before {
|
||||
content: "";
|
||||
.sleep-hour-axis {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
}
|
||||
.sleep-hour-axis > span {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sleep-hour-axis > span:first-child {
|
||||
transform: none;
|
||||
}
|
||||
.sleep-hour-axis > span:last-child {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.sleep-row {
|
||||
display: grid;
|
||||
|
|
@ -755,11 +773,12 @@ h2 {
|
|||
position: relative;
|
||||
height: 1.75rem;
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--surface-2);
|
||||
box-shadow: inset 0 0 0 1px var(--surface-2);
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
.sleep-event {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0.15rem;
|
||||
bottom: 0.15rem;
|
||||
min-width: 0.3rem;
|
||||
|
|
@ -768,6 +787,20 @@ h2 {
|
|||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
.sleep-point,
|
||||
.sleep-medication {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.sleep-point > span,
|
||||
.sleep-medication > span {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 0;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sleep-sleep,
|
||||
.sleep-nap {
|
||||
background: repeating-linear-gradient(
|
||||
|
|
@ -788,14 +821,15 @@ h2 {
|
|||
);
|
||||
}
|
||||
.sleep-medication {
|
||||
z-index: 2;
|
||||
}
|
||||
.sleep-medication > span {
|
||||
min-width: 1.35rem;
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--green);
|
||||
color: var(--crust);
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
z-index: 2;
|
||||
}
|
||||
.sleep-editor-columns {
|
||||
display: grid;
|
||||
|
|
|
|||
Loading…
Reference in a new issue