diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a6581..48c7d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index 45e500f..f5233aa 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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) | diff --git a/docs/reviews/evidence/sleep-diary-alignment-firefox.png b/docs/reviews/evidence/sleep-diary-alignment-firefox.png new file mode 100644 index 0000000..96cbd45 Binary files /dev/null and b/docs/reviews/evidence/sleep-diary-alignment-firefox.png differ diff --git a/docs/reviews/evidence/sleep-diary-empty-night-firefox.png b/docs/reviews/evidence/sleep-diary-empty-night-firefox.png new file mode 100644 index 0000000..24fb54e Binary files /dev/null and b/docs/reviews/evidence/sleep-diary-empty-night-firefox.png differ diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.pdf b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf index 6ccda4c..d8f1995 100644 --- a/docs/reviews/evidence/sleep-diary-fr-7-days.pdf +++ b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf @@ -13,7 +13,7 @@ endobj << /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >> endobj 5 0 obj -<< /Length 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 diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.png b/docs/reviews/evidence/sleep-diary-fr-7-days.png index bd1c2fe..5b45a33 100644 Binary files a/docs/reviews/evidence/sleep-diary-fr-7-days.png and b/docs/reviews/evidence/sleep-diary-fr-7-days.png differ diff --git a/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png index c5631d4..a3b6dcb 100644 Binary files a/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png and b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png differ diff --git a/docs/reviews/sleep_diary_active_night_geometry_v1_evidence.md b/docs/reviews/sleep_diary_active_night_geometry_v1_evidence.md new file mode 100644 index 0000000..4421d8b --- /dev/null +++ b/docs/reviews/sleep_diary_active_night_geometry_v1_evidence.md @@ -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. diff --git a/docs/tests.md b/docs/tests.md index 9b4a262..18a8e53 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 diff --git a/tests/test_web_sleep_browser.py b/tests/test_web_sleep_browser.py index 5092a67..ea3093a 100644 --- a/tests/test_web_sleep_browser.py +++ b/tests/test_web_sleep_browser.py @@ -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" diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index 06ef3d4..fd754a1 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -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(); + + 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(); + + 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((resolve) => { + resolveNightA = resolve; + }); + api.fetchSleepDiary + .mockImplementationOnce(async () => slowNightA) + .mockResolvedValue(emptySnapshot); + render(); + + 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(); + 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", + ); + }); }); diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 71cadeb..3de4f79 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -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(blank); + const [activeNight, setActiveNight] = useState(draft.night_start_date); const [eventType, setEventType] = useState("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("draft"); - const [saveState, setSaveState] = useState<"saved" | "saving" | "error">( - "saved", - ); + const [saveState, setSaveState] = useState< + "idle" | "saved" | "saving" | "error" + >("idle"); const [medications, setMedications] = useState([]); 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 (
@@ -638,17 +695,26 @@ export function SleepDiaryWorkspace({
- {entries.length === 0 ? ( -

{t.empty}

+ {agendaEntries.length === 0 ? ( +

{t.agendaEmpty}

) : (
- @@ -842,17 +928,19 @@ export function SleepDiaryWorkspace({ > {t.statuses[publicationStatus]} - - {saveState === "saving" - ? t.saving - : saveState === "saved" - ? t.saved - : error} - + {saveState !== "idle" && ( + + {saveState === "saving" + ? t.saving + : saveState === "saved" + ? t.saved + : error} + + )}
@@ -1353,46 +1441,60 @@ export function SleepDiaryWorkspace({
{language === "fr" ? "Nuits enregistrées" : "Recorded nights"}
-
{entries.length}
+
{activeEntry ? 1 : 0}
{language === "fr" ? "Sommeil déclaré" : "Declared sleep"}
- {formatDuration(snapshot?.summary.sleep_duration_seconds ?? 0)} + {activeFacts && activeFacts.sleepSeconds > 0 + ? formatDuration(activeFacts.sleepSeconds) + : "—"}
{language === "fr" ? "Longs réveils" : "Long awakenings"}
- {countAndDuration( - snapshot?.summary.long_awake_count ?? 0, - snapshot?.summary.long_awake_duration_seconds ?? 0, - )} + {activeFacts + ? countAndDuration( + activeFacts.longAwakeCount, + activeFacts.longAwakeSeconds, + ) + : "—"}
{language === "fr" ? "Siestes" : "Naps"}
- {countAndDuration( - snapshot?.summary.nap_count ?? 0, - snapshot?.summary.nap_duration_seconds ?? 0, - )} + {activeFacts + ? countAndDuration( + activeFacts.napCount, + activeFacts.napSeconds, + ) + : "—"}
+
+
{t.daytime_sleepiness}
+
{activeFacts ? activeFacts.sleepinessCount : "—"}
+
+
+
{language === "fr" ? "Prises" : "Intakes"}
+
{activeEntry ? activeEntry.intakes.length : "—"}
+

{t.observations}

- {entries - .filter((entry) => entry.treatment_and_notes) - .map((entry) => ( -

- {entry.night_start_date} —{" "} - {entry.treatment_and_notes} -

- ))} + {activeEntry?.treatment_and_notes ? ( +

+ {activeEntry.night_start_date} —{" "} + {activeEntry.treatment_and_notes} +

+ ) : ( +

—

+ )}
diff --git a/web/src/routes/sleepTimelineGeometry.test.ts b/web/src/routes/sleepTimelineGeometry.test.ts new file mode 100644 index 0000000..419d7e6 --- /dev/null +++ b/web/src/routes/sleepTimelineGeometry.test.ts @@ -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); + }); +}); diff --git a/web/src/routes/sleepTimelineGeometry.ts b/web/src/routes/sleepTimelineGeometry.ts new file mode 100644 index 0000000..03ede00 --- /dev/null +++ b/web/src/routes/sleepTimelineGeometry.ts @@ -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) }; +} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 73b930b..a01ff28 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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;