fix(web): align sleep agenda and isolate active nights

This commit is contained in:
fy59 2026-09-21 17:34:14 +02:00
parent 9652b35189
commit e6e5e76f68
15 changed files with 777 additions and 167 deletions

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -13,7 +13,7 @@ endobj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
endobj
5 0 obj
<< /Length 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

View file

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

View file

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

View file

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

View file

@ -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",
);
});
});

View file

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

View 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);
});
});

View 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) };
}

View file

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