diff --git a/CHANGELOG.md b/CHANGELOG.md index 893dba4..5146d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased — 0.1.4 +- Made the Sleep Diary Agenda a live projection of the latest locally + persisted entry revision. Event and intake mutations now re-read and adopt + the canonical Core snapshot, serialized autosaves carry the returned + revision forward, and stale asynchronous reads cannot regress the visible + state. The Agenda shows factual bedtime, final get-up, declared-sleep and + time-in-bed durations, long awakenings, naps, sleepiness and grouped + medication markers without requiring reload or publication. Medication + catalog entries and selectors now include their usual localized dose and + unit, keep same-name/different-dose records distinct, and prefill editable + intake doses. Schemas, domain semantics, synchronization and PDF generation + are unchanged. - Fixed the Sleep Diary Web workspace becoming entirely blank after adding an event or medication intake on the loopback-routed HTTP origin. Sleep event and intake UUIDv4 identities now use secure random bytes without depending diff --git a/docs/current_state.md b/docs/current_state.md index 5d572d9..f953a05 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 local vector PDF; the Web capture workspace has real-Firefox mutation/reload coverage and a section-local error boundary | +| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and 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 | | 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-live-agenda-firefox.png b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png new file mode 100644 index 0000000..c5631d4 Binary files /dev/null and b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png differ diff --git a/docs/reviews/sleep_diary_capture_ux_v1_evidence.md b/docs/reviews/sleep_diary_capture_ux_v1_evidence.md index 430f156..84e0be5 100644 --- a/docs/reviews/sleep_diary_capture_ux_v1_evidence.md +++ b/docs/reviews/sleep_diary_capture_ux_v1_evidence.md @@ -84,3 +84,30 @@ intakes. All nine mutations returned HTTP 200. Browser console errors, network fetch errors and unhandled rejections were all zero. The exact 390×844 viewport had zero document overflow. No main merge, tag or release was created. + +## Live persisted Agenda follow-up + +The Agenda now consumes the same canonical entry revision returned by Core as +the Chronology, factual summary, PDF and synchronization surfaces. After a +successful mutation the existing persistence queue re-reads the diary, finds +the exact returned `entry_id` and `revision_id`, and only then marks the entry +as locally saved. Request sequencing prevents an older GET from replacing a +newer projection, while the serialized mutation queue supplies each returned +revision to the next write. + +The compact row derives bedtime, final get-up, declared sleep, time in bed, +long-awakening totals, naps and sleepiness directly from the canonical event +list. Medication intakes at one timestamp use one grouped marker and retain +their factual dose snapshots. Catalog and selector labels use stable +medication identities and display localized usual dose/unit, so +same-name/different-dose records remain independently selectable. + +The isolated real-Firefox scenario observed, without reload, two venlafaxine +intakes at 22:00 (75 mg and 37.5 mg), bedtime at 22:45, sleep from 23:15 to +03:00, a long awakening from 03:00 to 03:30, sleep from 03:30 to 06:45 and +final get-up at 07:10. The Agenda immediately reported 7 h 00 min declared +sleep, 8 h 25 min in bed, one 30-minute long awakening and one grouped `M ×2` +marker. All captured requests returned HTTP 200, browser error and unhandled +rejection collectors stayed empty, and reload reproduced the same Agenda row. +The retained capture is +[`evidence/sleep-diary-live-agenda-firefox.png`](evidence/sleep-diary-live-agenda-firefox.png). diff --git a/docs/tests.md b/docs/tests.md index 08b2474..e00a9a3 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -249,6 +249,16 @@ clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest covers default/persisted/invalid sources, ETag/token parsing, canonical-only PUT, conflict translation, loading, local cancel/reset and durable Save. +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. + `WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed field and Vitest fixtures confined to test sources. Coverage proves the 90-day activity map and direct totals, all compact/medium/large disclosure levels, diff --git a/tests/test_web_sleep_browser.py b/tests/test_web_sleep_browser.py new file mode 100644 index 0000000..eac4331 --- /dev/null +++ b/tests/test_web_sleep_browser.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""Exercise the persisted Sleep Diary projection incrementally in Firefox.""" + +import json +import os +import signal +import socket +import subprocess +import tempfile +import time +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / os.environ.get("TRAINLOG_WEB_E2E_BUILD", "build") / "tui" +GECKO = Path( + os.environ.get( + "TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver" + ) +) +EVIDENCE = ROOT / "docs" / "reviews" / "evidence" + + +class BrowserSleepDiaryTest(unittest.TestCase): + def test_live_agenda_uses_each_persisted_revision_without_reload(self): + if not GECKO.is_file(): + self.skipTest("pinned geckodriver unavailable") + + try: + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.firefox.service import Service + from selenium.webdriver.support.ui import Select, WebDriverWait + import websocket + except ModuleNotFoundError: + self.skipTest("selenium unavailable") + + with tempfile.TemporaryDirectory(prefix="trainlog-web-sleep-") as directory: + root = Path(directory) + environment = os.environ.copy() + environment.update( + { + "XDG_DATA_HOME": str(root / "data"), + "XDG_CONFIG_HOME": str(root / "config"), + "XDG_CACHE_HOME": str(root / "cache"), + } + ) + with socket.socket() as reserved: + reserved.bind(("127.0.0.1", 0)) + port = reserved.getsockname()[1] + server = subprocess.Popen( + [str(BUILD / "trainlog"), "--web", "--port", str(port)], + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + driver = None + try: + for _ in range(200): + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", port)) == 0: + break + time.sleep(0.025) + + options = webdriver.FirefoxOptions() + options.add_argument("-headless") + options.set_capability("webSocketUrl", True) + driver = webdriver.Firefox(options=options, service=Service(str(GECKO))) + driver.set_window_size(1440, 1100) + wait = WebDriverWait(driver, 15) + driver.get(f"http://127.0.0.1:{port}/analyse?section=sleep") + wait.until( + lambda current: current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-workspace']" + ) + ) + driver.execute_script( + """ + window.__trainlogErrors = []; + window.__trainlogFetches = []; + addEventListener('error', event => window.__trainlogErrors.push(String(event.error || event.message))); + addEventListener('unhandledrejection', event => window.__trainlogErrors.push(String(event.reason))); + const originalFetch = window.fetch; + window.fetch = async (...args) => { + try { + const response = await originalFetch(...args); + window.__trainlogFetches.push({url: String(args[0]), method: args[1]?.method || 'GET', status: response.status}); + return response; + } catch (error) { + window.__trainlogErrors.push(String(error)); + throw error; + } + }; + """ + ) + + def set_value(test_id: str, value: str): + element = driver.find_element( + By.CSS_SELECTOR, f"[data-testid='{test_id}']" + ) + driver.execute_script( + "Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(arguments[0],arguments[1]);" + "arguments[0].dispatchEvent(new Event('input',{bubbles:true}));" + "arguments[0].dispatchEvent(new Event('change',{bubbles:true}));", + element, + value, + ) + + def click(element): + driver.execute_script( + "arguments[0].scrollIntoView({block:'center'}); arguments[0].click();", + element, + ) + + def post_count() -> int: + return driver.execute_script( + "return window.__trainlogFetches.filter(item => item.method === 'POST').length" + ) + + def await_persisted(previous_posts: int): + wait.until(lambda current: post_count() > previous_posts) + wait.until( + lambda current: current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-save-state']" + ).text + == "Enregistré localement" + ) + + def add_medication(dose: str): + click(driver.find_element( + By.XPATH, + "//button[normalize-space()='+ Ajouter un médicament']", + )) + set_value("sleep-medication-name", "venlafaxine") + set_value("sleep-medication-dose", dose) + before = post_count() + click(driver.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-add-medication']" + )) + wait.until(lambda current: post_count() > before) + wait.until( + lambda current: not current.find_elements( + By.CSS_SELECTOR, "[data-testid='sleep-medication-name']" + ) + ) + + add_medication("75") + add_medication("37.5") + medication_select = Select( + driver.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-intake-medication']" + ) + ) + option_labels = [option.text for option in medication_select.options] + self.assertIn("venlafaxine — 75 mg", option_labels) + self.assertIn("venlafaxine — 37,5 mg", option_labels) + + for label, dose in ( + ("venlafaxine — 75 mg", "75"), + ("venlafaxine — 37,5 mg", "37.5"), + ): + medication_select.select_by_visible_text(label) + wait.until( + lambda current: current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-intake-dose']" + ).get_attribute("value") + == dose + ) + before = post_count() + click(driver.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-add-intake']" + )) + await_persisted(before) + wait.until( + lambda current: current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-agenda-medication']" + ).text + == "M ×2" + ) + + def add_event(kind: str, start: str, end: str | None, assertion): + Select( + driver.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-event-type']" + ) + ).select_by_value(kind) + set_value("sleep-event-start", start) + if end is not None: + set_value("sleep-event-end", end) + before = post_count() + click(driver.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-add-event']" + )) + await_persisted(before) + wait.until(assertion) + + add_event( + "bed_time", + "22:45", + None, + lambda current: current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-agenda-bed-time']" + ).text.endswith("22:45"), + ) + add_event( + "sleep", + "23:15", + "03:00", + lambda current: "3 h 45 min" + in current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-agenda-sleep-duration']" + ).text, + ) + add_event( + "long_awake", + "03:00", + "03:30", + lambda current: "1 · 30 min" + in current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-agenda-long-awake']" + ).text, + ) + add_event( + "sleep", + "03:30", + "06:45", + lambda current: "7 h 00 min" + in current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-agenda-sleep-duration']" + ).text, + ) + add_event( + "final_get_up", + "07:10", + None, + lambda current: "8 h 25 min" + in current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-agenda-time-in-bed']" + ).text, + ) + + expected = driver.find_element( + By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']" + ).text + mutation_errors = driver.execute_script( + "return window.__trainlogErrors" + ) + mutation_fetches = driver.execute_script( + "return window.__trainlogFetches" + ) + self.assertEqual([], mutation_errors) + self.assertTrue(mutation_fetches) + self.assertTrue( + all(item["status"] == 200 for item in mutation_fetches) + ) + driver.refresh() + wait.until( + lambda current: "8 h 25 min" + in current.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-agenda-time-in-bed']" + ).text + ) + actual = driver.find_element( + By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']" + ).text + self.assertEqual(expected, actual) + self.assertTrue( + driver.find_element( + By.CSS_SELECTOR, "[data-testid='sleep-workspace']" + ).is_displayed() + ) + self.assertLessEqual( + driver.execute_script("return document.documentElement.scrollWidth"), + driver.execute_script("return document.documentElement.clientWidth"), + ) + bidi = websocket.create_connection( + driver.capabilities["webSocketUrl"], suppress_origin=True + ) + bidi.send( + json.dumps( + {"id": 1, "method": "browsingContext.getTree", "params": {}} + ) + ) + tree = json.loads(bidi.recv()) + context = tree["result"]["contexts"][0]["context"] + bidi.send( + json.dumps( + { + "id": 2, + "method": "browsingContext.setViewport", + "params": { + "context": context, + "viewport": {"width": 390, "height": 844}, + "devicePixelRatio": 1, + }, + } + ) + ) + response = json.loads(bidi.recv()) + self.assertEqual("success", response["type"]) + wait.until(lambda current: current.execute_script("return innerWidth") == 390) + self.assertLessEqual( + driver.execute_script("return document.documentElement.scrollWidth"), + driver.execute_script("return document.documentElement.clientWidth"), + ) + bidi.send( + json.dumps( + { + "id": 3, + "method": "browsingContext.setViewport", + "params": { + "context": context, + "viewport": {"width": 1440, "height": 1100}, + "devicePixelRatio": 1, + }, + } + ) + ) + self.assertEqual("success", json.loads(bidi.recv())["type"]) + bidi.close() + EVIDENCE.mkdir(parents=True, exist_ok=True) + self.assertTrue( + driver.save_screenshot( + str(EVIDENCE / "sleep-diary-live-agenda-firefox.png") + ) + ) + finally: + if driver is not None: + driver.quit() + try: + os.killpg(server.pid, signal.SIGTERM) + except ProcessLookupError: + pass + server.wait(timeout=10) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts index 66ea7af..b63e6b1 100644 --- a/web/src/api/sleepDiary.ts +++ b/web/src/api/sleepDiary.ts @@ -284,7 +284,7 @@ export async function saveSleepMedication( created_at: string; updated_at: string; }, -): Promise { +): Promise<{ medication_id: string; revision_id: string }> { const csrf = await mutationCsrfToken(); const response = await fetch("/api/v1/sleep-medications", { method: "POST", @@ -295,5 +295,13 @@ export async function saveSleepMedication( }, body: JSON.stringify(input), }); - if (!response.ok) throw new Error("sleep_medication_mutation_failed"); + const value: unknown = await response.json(); + if ( + !response.ok || + !object(value) || + typeof value.medication_id !== "string" || + typeof value.revision_id !== "string" + ) + throw new Error("sleep_medication_mutation_failed"); + return value as { medication_id: string; revision_id: string }; } diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index ee5139d..06ef3d4 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -6,7 +6,12 @@ import { within, } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SleepMedication, SleepSnapshot } from "../api/sleepDiary"; +import type { + SleepEntry, + SleepEntryInput, + SleepMedication, + SleepSnapshot, +} from "../api/sleepDiary"; import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace"; const api = vi.hoisted(() => ({ @@ -62,6 +67,55 @@ const medication: SleepMedication = { active: true, }; +const snapshotWith = (entries: SleepEntry[]): SleepSnapshot => { + const duration = (start: string, end: string | null) => + end === null ? 0 : (Date.parse(end) - Date.parse(start)) / 1000; + const events = entries.flatMap((entry) => entry.events); + const selected = (type: string) => + events.filter((event) => event.type === type); + return { + api_version: 1, + entries, + summary: { + nights: entries.length, + long_awake_count: selected("long_awake").length, + nap_count: selected("nap").length, + sleepiness_count: selected("daytime_sleepiness").length, + intake_count: entries.reduce( + (total, entry) => total + entry.intakes.length, + 0, + ), + sleep_duration_seconds: selected("sleep").reduce( + (total, event) => total + duration(event.start_at, event.end_at), + 0, + ), + long_awake_duration_seconds: selected("long_awake").reduce( + (total, event) => total + duration(event.start_at, event.end_at), + 0, + ), + nap_duration_seconds: selected("nap").reduce( + (total, event) => total + duration(event.start_at, event.end_at), + 0, + ), + average_bed_minute: null, + average_get_up_minute: null, + }, + }; +}; + +const persistedEntry = ( + input: SleepEntryInput, + entryId: string, + revisionId: string, +): SleepEntry => ({ + ...input, + entry_id: entryId, + revision_id: revisionId, + publication_status: "draft", + events: input.events.map((event) => ({ ...event })), + intakes: input.intakes.map((intake) => ({ ...intake })), +}); + describe("SleepDiaryWorkspace", () => { beforeEach(() => { vi.clearAllMocks(); @@ -185,4 +239,268 @@ describe("SleepDiaryWorkspace", () => { expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument(); expect(screen.getByTestId("sleep-add-event")).toBeEnabled(); }); + + it("projects every persisted revision live and keeps distinct medication doses", async () => { + let persisted: SleepEntry[] = []; + let catalog: SleepMedication[] = []; + let revision = 0; + api.fetchSleepDiary.mockImplementation(async () => snapshotWith(persisted)); + api.fetchSleepMedications.mockImplementation(async () => catalog); + api.saveSleepEntry.mockImplementation(async (input: SleepEntryInput) => { + const entryId = + input.entry_id || "sd_00000000-0000-4000-8000-000000000001"; + const revisionId = `sdr_00000000-0000-4000-8000-${String(++revision).padStart(12, "0")}`; + persisted = [persistedEntry(input, entryId, revisionId)]; + return { entry_id: entryId, revision_id: revisionId }; + }); + api.saveSleepMedication.mockImplementation(async (input) => { + const index = catalog.length + 1; + const saved: SleepMedication = { + ...input, + medication_id: `med_00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + revision_id: `mr_00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + }; + catalog = [...catalog, saved]; + return { + medication_id: saved.medication_id, + revision_id: saved.revision_id, + }; + }); + + render(); + await screen.findByText("Aucun médicament enregistré."); + + const addMedication = async (dose: string) => { + fireEvent.click( + screen.getByRole("button", { name: "+ Ajouter un médicament" }), + ); + fireEvent.change(screen.getByTestId("sleep-medication-name"), { + target: { value: "venlafaxine" }, + }); + fireEvent.change(screen.getByTestId("sleep-medication-dose"), { + target: { value: dose }, + }); + fireEvent.click(screen.getByTestId("sleep-add-medication")); + await waitFor(() => + expect(screen.queryByTestId("sleep-medication-name")).toBeNull(), + ); + }; + await addMedication("75"); + await addMedication("37.5"); + + const medicationSelect = screen.getByTestId("sleep-intake-medication"); + expect(medicationSelect).toHaveTextContent("venlafaxine — 75 mg"); + expect(medicationSelect).toHaveTextContent("venlafaxine — 37,5 mg"); + expect(screen.getAllByText("venlafaxine")).toHaveLength(2); + + const addIntake = async (medicationId: string, expectedDose: string) => { + const calls = api.saveSleepEntry.mock.calls.length; + fireEvent.change(medicationSelect, { target: { value: medicationId } }); + expect(screen.getByTestId("sleep-intake-dose")).toHaveValue( + Number(expectedDose), + ); + fireEvent.click(screen.getByTestId("sleep-add-intake")); + await waitFor(() => + expect(api.saveSleepEntry).toHaveBeenCalledTimes(calls + 1), + ); + await waitFor(() => + expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( + "Enregistré localement", + ), + ); + }; + await addIntake(catalog[0].medication_id, "75"); + await addIntake(catalog[1].medication_id, "37.5"); + expect(screen.getByTestId("sleep-agenda-medication")).toHaveTextContent( + "M ×2", + ); + expect(screen.getByTestId("sleep-agenda-medication")).toHaveAttribute( + "title", + expect.stringContaining("venlafaxine — 37,5 mg"), + ); + + const addEvent = async (type: string, start: string, end?: string) => { + const calls = api.saveSleepEntry.mock.calls.length; + fireEvent.change(screen.getByTestId("sleep-event-type"), { + target: { value: type }, + }); + fireEvent.change(screen.getByTestId("sleep-event-start"), { + target: { value: start }, + }); + if (end !== undefined) + fireEvent.change(screen.getByTestId("sleep-event-end"), { + target: { value: end }, + }); + fireEvent.click(screen.getByTestId("sleep-add-event")); + await waitFor(() => + expect(api.saveSleepEntry).toHaveBeenCalledTimes(calls + 1), + ); + await waitFor(() => + expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( + "Enregistré localement", + ), + ); + }; + await addEvent("bed_time", "22:45"); + expect(screen.getByTestId("sleep-agenda-bed-time")).toHaveTextContent( + "22:45", + ); + await addEvent("sleep", "23:15", "03:00"); + expect(screen.getByTestId("sleep-agenda-sleep-duration")).toHaveTextContent( + "3 h 45 min", + ); + await addEvent("long_awake", "03:00", "03:30"); + expect(screen.getByTestId("sleep-agenda-long-awake")).toHaveTextContent( + "1 · 30 min", + ); + await addEvent("sleep", "03:30", "06:45"); + expect(screen.getByTestId("sleep-agenda-sleep-duration")).toHaveTextContent( + "7 h 00 min", + ); + await addEvent("final_get_up", "07:10"); + expect(screen.getByTestId("sleep-agenda-final-get-up")).toHaveTextContent( + "07:10", + ); + expect(screen.getByTestId("sleep-agenda-time-in-bed")).toHaveTextContent( + "8 h 25 min", + ); + + const getUpRow = screen.getByTestId("sleep-event-final_get_up"); + fireEvent.change(within(getUpRow).getByLabelText("Heure"), { + target: { value: "07:30" }, + }); + await waitFor(() => + expect(screen.getByTestId("sleep-agenda-time-in-bed")).toHaveTextContent( + "8 h 45 min", + ), + ); + + const sleepRows = screen.getAllByTestId("sleep-event-sleep"); + fireEvent.change(within(sleepRows[1]).getByLabelText("Fin"), { + target: { value: "07:00" }, + }); + await waitFor(() => + expect( + screen.getByTestId("sleep-agenda-sleep-duration"), + ).toHaveTextContent("7 h 15 min"), + ); + + fireEvent.click( + within(screen.getByTestId("sleep-event-long_awake")).getByRole("button", { + name: "Supprimer Long réveil", + }), + ); + await waitFor(() => + expect(screen.queryByTestId("sleep-agenda-long-awake")).toBeNull(), + ); + expect(screen.queryByTestId("sleep-event-long_awake")).toBeNull(); + await addEvent("nap", "14:00", "14:35"); + expect(screen.getByTestId("sleep-agenda-naps")).toHaveTextContent( + "1 · 35 min", + ); + await addEvent("daytime_sleepiness", "15:00"); + expect(screen.getByTestId("sleep-agenda-sleepiness")).toHaveTextContent( + "1 occurrence", + ); + expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument(); + }); + + it("ignores a stale diary read that finishes after a newer persisted revision", async () => { + let resolveInitial: ((value: SleepSnapshot) => void) | undefined; + const initial = new Promise((resolve) => { + resolveInitial = resolve; + }); + let persisted: SleepEntry[] = []; + api.fetchSleepDiary + .mockImplementationOnce(async () => initial) + .mockImplementation(async () => snapshotWith(persisted)); + api.saveSleepEntry.mockImplementation(async (input: SleepEntryInput) => { + const result = { + entry_id: "sd_00000000-0000-4000-8000-000000000001", + revision_id: "sdr_00000000-0000-4000-8000-000000000099", + }; + persisted = [persistedEntry(input, result.entry_id, result.revision_id)]; + return result; + }); + + render(); + fireEvent.change(screen.getByTestId("sleep-event-type"), { + target: { value: "bed_time" }, + }); + fireEvent.change(screen.getByTestId("sleep-event-start"), { + target: { value: "22:45" }, + }); + fireEvent.click(screen.getByTestId("sleep-add-event")); + expect( + await screen.findByTestId("sleep-agenda-event-bed_time"), + ).toBeInTheDocument(); + + resolveInitial?.(emptySnapshot); + await Promise.resolve(); + expect(screen.getByTestId("sleep-agenda-bed-time")).toHaveTextContent( + "22:45", + ); + }); + + it("serializes autosaves so the next mutation uses the returned revision", async () => { + let resolveFirst: + ((value: { entry_id: string; revision_id: string }) => void) | undefined; + let persisted: SleepEntry[] = []; + const first = new Promise<{ entry_id: string; revision_id: string }>( + (resolve) => { + resolveFirst = resolve; + }, + ); + api.fetchSleepDiary.mockImplementation(async () => snapshotWith(persisted)); + api.saveSleepEntry + .mockImplementationOnce(async () => first) + .mockImplementationOnce(async (input: SleepEntryInput) => { + const result = { + entry_id: input.entry_id, + revision_id: "sdr_00000000-0000-4000-8000-000000000002", + }; + persisted = [ + persistedEntry(input, result.entry_id, result.revision_id), + ]; + return result; + }); + + render(); + await screen.findByTestId("sleep-workspace"); + fireEvent.change(screen.getByTestId("sleep-event-type"), { + target: { value: "bed_time" }, + }); + fireEvent.click(screen.getByTestId("sleep-add-event")); + fireEvent.change(screen.getByTestId("sleep-event-type"), { + target: { value: "sleep" }, + }); + fireEvent.click(screen.getByTestId("sleep-add-event")); + await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(1)); + expect(api.saveSleepEntry).toHaveBeenCalledTimes(1); + + const firstInput = api.saveSleepEntry.mock.calls[0][0] as SleepEntryInput; + const firstResult = { + entry_id: "sd_00000000-0000-4000-8000-000000000001", + revision_id: "sdr_00000000-0000-4000-8000-000000000001", + }; + persisted = [ + persistedEntry(firstInput, firstResult.entry_id, firstResult.revision_id), + ]; + resolveFirst?.(firstResult); + + await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2)); + expect(api.saveSleepEntry.mock.calls[1][0]).toMatchObject({ + entry_id: firstResult.entry_id, + expected_revision: firstResult.revision_id, + }); + await waitFor(() => + expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( + "Enregistré localement", + ), + ); + expect( + screen.getByTestId("sleep-agenda-event-bed_time"), + ).toBeInTheDocument(); + expect(screen.getByTestId("sleep-agenda-event-sleep")).toBeInTheDocument(); + }); }); diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 6582c26..71cadeb 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -21,6 +21,8 @@ import { buildSleepDiaryPdf, presentSleepDiaryPdf, } from "../report/sleepDiaryPdf"; +import { formatDose, formatDuration } from "../dashboard/dashboardFormat"; +import { sleepEntryFacts } from "./sleepDiaryFacts"; type Language = "fr" | "en"; const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"]; @@ -152,6 +154,23 @@ const copy = { } as const; const clock = (iso: string) => (iso ? iso.slice(11, 16) : ""); +const medicationLabel = (medication: SleepMedication) => + medication.default_dose_value === null + ? medication.name + : `${medication.name} — ${formatDose(medication.default_dose_value)} ${medication.default_dose_unit ?? ""}`.trim(); +const intakeLabel = (intake: MedicationIntake) => + intake.dose_value === null + ? intake.medication_name + : `${intake.medication_name} — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}`.trim(); +const countAndDuration = (count: number, seconds: number) => + `${count} · ${formatDuration(seconds)}`; + +const canonicalInput = (entry: SleepEntry): SleepEntryInput => ({ + ...entry, + expected_revision: entry.revision_id, + events: entry.events.map((event) => ({ ...event })), + intakes: entry.intakes.map((intake) => ({ ...intake })), +}); const atClock = (value: string, startDate: string, endDate: string) => { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return ""; const hour = Number(value.slice(0, 2)); @@ -264,6 +283,9 @@ export function SleepDiaryWorkspace({ null, ); const persistence = useRef>(Promise.resolve()); + const mutationSequence = useRef(0); + const diaryReadSequence = useRef(0); + const medicationReadSequence = useRef(0); const range = useMemo(() => { if (period === "all") return {}; const days = Number(period.slice(0, -1)); @@ -275,37 +297,48 @@ export function SleepDiaryWorkspace({ endDate: end.toLocaleDateString("en-CA"), }; }, [period]); - const reload = () => - Promise.all([ - fetchSleepDiary(range.startDate, range.endDate), - fetchSleepMedications(), - ]) - .then(([diary, catalog]) => { - setSnapshot(diary); - setMedications(catalog); - if (identity.current === null) { - const resume = - diary.entries.find( - (entry) => - entry.publication_status === "draft" || - entry.publication_status === "modified", - ) ?? diary.entries[0]; - if (resume) { - identity.current = { - entry_id: resume.entry_id, - revision_id: resume.revision_id, - }; - setPublicationStatus(resume.publication_status); - setDraft({ - ...resume, - expected_revision: resume.revision_id, - events: resume.events.map((event) => ({ ...event })), - intakes: resume.intakes.map((intake) => ({ ...intake })), - }); - } - } - }) - .catch(() => setError("sleep_diary_unavailable")); + const readDiary = async () => { + const read = ++diaryReadSequence.current; + const diary = await fetchSleepDiary(range.startDate, range.endDate); + // INVARIANT: an older GET may finish after a newer mutation/reload GET; + // only the newest requested snapshot can replace the visible projection. + if (read === diaryReadSequence.current) setSnapshot(diary); + return diary; + }; + const readMedications = async () => { + const read = ++medicationReadSequence.current; + const catalog = await fetchSleepMedications(); + if (read === medicationReadSequence.current) setMedications(catalog); + return catalog; + }; + const adoptEntry = (entry: SleepEntry) => { + identity.current = { + entry_id: entry.entry_id, + revision_id: entry.revision_id, + }; + setPublicationStatus(entry.publication_status); + setDraft(canonicalInput(entry)); + }; + const reload = async () => { + 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); + } catch { + setError("sleep_diary_unavailable"); + } + }; useEffect(() => { void reload(); }, [period]); @@ -314,18 +347,13 @@ export function SleepDiaryWorkspace({ entry_id: entry.entry_id, revision_id: entry.revision_id, }; - setPublicationStatus(entry.publication_status); - setDraft({ - ...entry, - expected_revision: entry.revision_id, - events: entry.events.map((event) => ({ ...event })), - intakes: entry.intakes.map((intake) => ({ ...intake })), - }); + adoptEntry(entry); }; const persist = (next: SleepEntryInput) => { + const sequence = ++mutationSequence.current; setSaveState("saving"); - persistence.current = persistence.current - .then(async () => { + persistence.current = persistence.current.then(async () => { + try { const current = identity.current; const result = await saveSleepEntry({ ...next, @@ -334,26 +362,43 @@ export function SleepDiaryWorkspace({ updated_at: sleepTimestamp(), }); identity.current = result; - setPublicationStatus((status) => - status === "synchronized" || status === "modified" - ? "modified" - : "draft", + const diary = await readDiary(); + const persisted = diary.entries.find( + (entry) => + entry.entry_id === result.entry_id && + entry.revision_id === result.revision_id, ); - setSaveState("saved"); - setDraft((value) => ({ - ...value, - entry_id: result.entry_id, - expected_revision: result.revision_id, - })); - }) - .catch((reason: unknown) => { + if (!persisted) throw new TypeError("sleep_diary_revision_unavailable"); + if (sequence === mutationSequence.current) { + adoptEntry(persisted); + setError(""); + setSaveState("saved"); + } + } catch (reason: unknown) { + if (sequence !== mutationSequence.current) return; + try { + const diary = await readDiary(); + const current = identity.current; + const persisted = current + ? diary.entries.find( + (entry) => + entry.entry_id === current.entry_id && + entry.revision_id === current.revision_id, + ) + : undefined; + if (persisted) adoptEntry(persisted); + } catch { + // Preserve the original mutation diagnostic when reconciliation also + // fails; the stale Agenda remains visibly distinct from the draft. + } setSaveState("error"); setError( reason instanceof Error ? reason.message : "sleep_diary_mutation_failed", ); - }); + } + }); }; const mutate = (transform: (current: SleepEntryInput) => SleepEntryInput) => setDraft((current) => { @@ -444,11 +489,10 @@ export function SleepDiaryWorkspace({ const addMedication = async () => { if (!medName.trim()) return; const now = sleepTimestamp(); - const savedName = medName.trim(); setPending(true); setError(""); try { - await saveSleepMedication({ + const result = await saveSleepMedication({ medication_id: editingMedication?.medication_id ?? "", expected_revision: editingMedication?.revision_id ?? null, created_at: now, @@ -460,12 +504,10 @@ export function SleepDiaryWorkspace({ note: editingMedication?.note ?? "", active: editingMedication?.active ?? true, }); - const catalog = await fetchSleepMedications(); - setMedications(catalog); - const saved = - catalog.find( - (item) => item.medication_id === editingMedication?.medication_id, - ) ?? catalog.find((item) => item.name === savedName); + const catalog = await readMedications(); + const saved = catalog.find( + (item) => item.medication_id === result.medication_id, + ); if (saved) { setIntakeMedication(saved.medication_id); setIntakeDose(saved.default_dose_value?.toString() ?? ""); @@ -606,69 +648,164 @@ export function SleepDiaryWorkspace({ {(18 + index) % 24} ))} - {entries.map((entry) => ( - - ))} + ))} + + + + + {t.bed_time} + {facts.bedTime ?? "—"} + + + {t.final_get_up} + {facts.finalGetUp ?? "—"} + + + {t.sleep} + + {facts.sleepSeconds > 0 + ? formatDuration(facts.sleepSeconds) + : "—"} + + + + + {language === "fr" ? "Temps au lit" : "Time in bed"} + + + {facts.timeInBedSeconds === null + ? "—" + : formatDuration(facts.timeInBedSeconds)} + + + {facts.longAwakeCount > 0 && ( + + + {language === "fr" ? "Réveils" : "Awakenings"} + + + {countAndDuration( + facts.longAwakeCount, + facts.longAwakeSeconds, + )} + + + )} + {facts.napCount > 0 && ( + + {t.nap} + + {countAndDuration( + facts.napCount, + facts.napSeconds, + )} + + + )} + {facts.sleepinessCount > 0 && ( + + {t.daytime_sleepiness} + + {facts.sleepinessCount}{" "} + {language === "fr" + ? facts.sleepinessCount === 1 + ? "occurrence" + : "occurrences" + : facts.sleepinessCount === 1 + ? "occurrence" + : "occurrences"} + + + )} + + + + {t.sleep} + {entry.sleep_quality ?? "—"} + + + {language === "fr" ? "Réveil" : "Wake"} + {entry.wake_quality ?? "—"} + + + {language === "fr" ? "Journée" : "Day"} + {entry.day_form ?? "—"} + + + + + ); + })} )} @@ -984,7 +1121,7 @@ export function SleepDiaryWorkspace({ .filter((item) => item.active) .map((item) => ( ))} @@ -1156,7 +1293,7 @@ export function SleepDiaryWorkspace({ {item.default_dose_value === null ? "—" - : `${item.default_dose_value} ${item.default_dose_unit ?? ""}`}{" "} + : `${formatDose(item.default_dose_value)} ${item.default_dose_unit ?? ""}`}{" "} {language === "fr" ? "habituel" : "usual"} @@ -1223,19 +1360,26 @@ export function SleepDiaryWorkspace({ {language === "fr" ? "Sommeil déclaré" : "Declared sleep"}
- {Math.round( - (snapshot?.summary.sleep_duration_seconds ?? 0) / 60, - )}{" "} - min + {formatDuration(snapshot?.summary.sleep_duration_seconds ?? 0)}
{language === "fr" ? "Longs réveils" : "Long awakenings"}
-
{snapshot?.summary.long_awake_count ?? 0}
+
+ {countAndDuration( + snapshot?.summary.long_awake_count ?? 0, + snapshot?.summary.long_awake_duration_seconds ?? 0, + )} +
{language === "fr" ? "Siestes" : "Naps"}
-
{snapshot?.summary.nap_count ?? 0}
+
+ {countAndDuration( + snapshot?.summary.nap_count ?? 0, + snapshot?.summary.nap_duration_seconds ?? 0, + )} +
diff --git a/web/src/routes/sleepDiaryFacts.test.ts b/web/src/routes/sleepDiaryFacts.test.ts new file mode 100644 index 0000000..173aa44 --- /dev/null +++ b/web/src/routes/sleepDiaryFacts.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { SleepEntry } from "../api/sleepDiary"; +import { sleepEntryFacts } from "./sleepDiaryFacts"; + +const entry: SleepEntry = { + entry_id: "sl_00000000-0000-4000-8000-000000000001", + revision_id: "slr_00000000-0000-4000-8000-000000000001", + night_start_date: "2026-09-20", + night_end_date: "2026-09-21", + created_at: "2026-09-21T08:00:00Z", + updated_at: "2026-09-21T08:00:00Z", + publication_status: "draft", + sleep_quality: null, + wake_quality: null, + day_form: null, + treatment_and_notes: "", + events: [ + { + event_id: "bed", + type: "bed_time", + start_at: "2026-09-20T22:45:00+02:00", + end_at: null, + }, + { + event_id: "sleep-1", + type: "sleep", + start_at: "2026-09-20T23:15:00+02:00", + end_at: "2026-09-21T03:00:00+02:00", + }, + { + event_id: "awake", + type: "long_awake", + start_at: "2026-09-21T03:00:00+02:00", + end_at: "2026-09-21T03:30:00+02:00", + }, + { + event_id: "half", + type: "half_sleep", + start_at: "2026-09-21T03:20:00+02:00", + end_at: "2026-09-21T03:30:00+02:00", + }, + { + event_id: "sleep-2", + type: "sleep", + start_at: "2026-09-21T03:30:00+02:00", + end_at: "2026-09-21T06:45:00+02:00", + }, + { + event_id: "nap", + type: "nap", + start_at: "2026-09-21T14:00:00+02:00", + end_at: "2026-09-21T14:35:00+02:00", + }, + { + event_id: "sleepy", + type: "daytime_sleepiness", + start_at: "2026-09-21T15:00:00+02:00", + end_at: null, + }, + { + event_id: "up", + type: "final_get_up", + start_at: "2026-09-21T07:10:00+02:00", + end_at: null, + }, + ], + intakes: [], +}; + +describe("sleepEntryFacts", () => { + it("keeps declared sleep, half-sleep, naps and time in bed factually distinct", () => { + expect(sleepEntryFacts(entry)).toEqual({ + bedTime: "22:45", + finalGetUp: "07:10", + sleepSeconds: 7 * 3600, + timeInBedSeconds: 8 * 3600 + 25 * 60, + longAwakeCount: 1, + longAwakeSeconds: 30 * 60, + napCount: 1, + napSeconds: 35 * 60, + sleepinessCount: 1, + }); + }); +}); diff --git a/web/src/routes/sleepDiaryFacts.ts b/web/src/routes/sleepDiaryFacts.ts new file mode 100644 index 0000000..6eda2cd --- /dev/null +++ b/web/src/routes/sleepDiaryFacts.ts @@ -0,0 +1,64 @@ +import type { SleepEntry, SleepEvent } from "../api/sleepDiary"; + +export interface SleepEntryFacts { + bedTime: string | null; + finalGetUp: string | null; + sleepSeconds: number; + timeInBedSeconds: number | null; + longAwakeCount: number; + longAwakeSeconds: number; + napCount: number; + napSeconds: number; + sleepinessCount: number; +} + +const durationSeconds = (event: SleepEvent): number => { + if (event.end_at === null) return 0; + const start = Date.parse(event.start_at); + const end = Date.parse(event.end_at); + return Number.isFinite(start) && Number.isFinite(end) && end > start + ? Math.floor((end - start) / 1000) + : 0; +}; + +const clock = (timestamp: string | null): string | null => + timestamp === null ? null : timestamp.slice(11, 16); + +export function sleepEntryFacts(entry: SleepEntry): SleepEntryFacts { + const bedtime = entry.events.find((event) => event.type === "bed_time"); + const finalGetUp = [...entry.events] + .reverse() + .find((event) => event.type === "final_get_up"); + const interval = + bedtime !== undefined && finalGetUp !== undefined + ? Date.parse(finalGetUp.start_at) - Date.parse(bedtime.start_at) + : Number.NaN; + const events = (type: SleepEvent["type"]) => + entry.events.filter((event) => event.type === type); + const sleep = events("sleep"); + const longAwake = events("long_awake"); + const naps = events("nap"); + return { + bedTime: clock(bedtime?.start_at ?? null), + finalGetUp: clock(finalGetUp?.start_at ?? null), + sleepSeconds: sleep.reduce( + (total, event) => total + durationSeconds(event), + 0, + ), + timeInBedSeconds: + Number.isFinite(interval) && interval > 0 + ? Math.floor(interval / 1000) + : null, + longAwakeCount: longAwake.length, + longAwakeSeconds: longAwake.reduce( + (total, event) => total + durationSeconds(event), + 0, + ), + napCount: naps.length, + napSeconds: naps.reduce( + (total, event) => total + durationSeconds(event), + 0, + ), + sleepinessCount: events("daytime_sleepiness").length, + }; +} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 4527140..73b930b 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -679,13 +679,13 @@ h2 { max-width: 100%; } .sleep-agenda { - min-width: 900px; + min-width: 1120px; display: grid; gap: 0.35rem; } .sleep-hours { display: grid; - grid-template-columns: 13rem repeat(25, 1fr) 9rem; + grid-template-columns: 12rem repeat(25, 1fr) 25rem; color: var(--subtext-0); font-size: 0.72rem; } @@ -694,7 +694,7 @@ h2 { } .sleep-row { display: grid; - grid-template-columns: 13rem 1fr 9rem; + grid-template-columns: 12rem minmax(28rem, 1fr) 25rem; align-items: center; gap: 0.6rem; min-height: 3rem; @@ -717,6 +717,40 @@ h2 { color: var(--subtext-0); font-weight: 500; } +.sleep-row-summary, +.sleep-row-facts, +.sleep-row-qualities, +.sleep-row-facts > span, +.sleep-row-qualities > span { + display: flex; +} +.sleep-row-summary { + flex-direction: column; + gap: 0.4rem; +} +.sleep-row-facts, +.sleep-row-qualities { + gap: 0.65rem; + flex-wrap: wrap; +} +.sleep-row-facts > span, +.sleep-row-qualities > span { + min-width: 4.2rem; + flex-direction: column; + gap: 0.05rem; +} +.sleep-row-summary small { + font-size: 0.68rem; +} +.sleep-row-summary b { + color: var(--text); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} +.sleep-row-qualities { + padding-top: 0.35rem; + border-top: 1px solid var(--surface-1); +} .sleep-track { position: relative; height: 1.75rem; @@ -754,7 +788,14 @@ h2 { ); } .sleep-medication { + 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; @@ -869,10 +910,9 @@ h2 { border-radius: 0.5rem; } .sleep-timeline-editor .sleep-event-list li { - grid-template-columns: 6.5rem minmax(8rem, 1.4fr) repeat( - 2, - minmax(6rem, 0.75fr) - ) auto auto; + grid-template-columns: + 6.5rem minmax(8rem, 1.4fr) repeat(2, minmax(6rem, 0.75fr)) + auto auto; } .sleep-timeline-editor time { color: var(--lavender);