fix(web): refresh sleep agenda from persisted revisions
This commit is contained in:
parent
a98a4440ed
commit
20fe562cd8
12 changed files with 1186 additions and 140 deletions
11
CHANGELOG.md
11
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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
BIN
docs/reviews/evidence/sleep-diary-live-agenda-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-live-agenda-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
340
tests/test_web_sleep_browser.py
Normal file
340
tests/test_web_sleep_browser.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -284,7 +284,7 @@ export async function saveSleepMedication(
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
): 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
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<SleepSnapshot>((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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void>>(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({
|
|||
<span key={index}>{(18 + index) % 24}</span>
|
||||
))}
|
||||
</div>
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
className="sleep-row"
|
||||
key={entry.entry_id}
|
||||
onClick={() => edit(entry)}
|
||||
>
|
||||
<strong>
|
||||
{t.night} {entry.night_start_date}
|
||||
<small>
|
||||
{t.statuses[entry.publication_status]} · {t.continue}
|
||||
</small>
|
||||
</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,
|
||||
)
|
||||
: 1;
|
||||
return (
|
||||
{entries.map((entry) => {
|
||||
const facts = sleepEntryFacts(entry);
|
||||
const groupedIntakes = Object.entries(
|
||||
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
||||
(groups, intake) => {
|
||||
(groups[intake.taken_at] ??= []).push(intake);
|
||||
return groups;
|
||||
},
|
||||
{},
|
||||
),
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="sleep-row"
|
||||
data-testid={`sleep-agenda-row-${entry.entry_id}`}
|
||||
key={entry.entry_id}
|
||||
onClick={() => edit(entry)}
|
||||
>
|
||||
<strong>
|
||||
{t.night} {entry.night_start_date}
|
||||
<small>
|
||||
{t.statuses[entry.publication_status]} · {t.continue}
|
||||
</small>
|
||||
</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,
|
||||
)
|
||||
: 1;
|
||||
return (
|
||||
<i
|
||||
key={event.event_id}
|
||||
className={`sleep-event sleep-${event.type}`}
|
||||
data-testid={`sleep-agenda-event-${event.type}`}
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
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"
|
||||
: ""}
|
||||
</i>
|
||||
);
|
||||
})}
|
||||
{groupedIntakes.map(([takenAt, intakes]) => (
|
||||
<i
|
||||
key={event.event_id}
|
||||
className={`sleep-event sleep-${event.type}`}
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ""}`}
|
||||
key={takenAt}
|
||||
className="sleep-event sleep-medication"
|
||||
data-testid="sleep-agenda-medication"
|
||||
style={{
|
||||
left: `${offset(takenAt, entry.night_start_date)}%`,
|
||||
width: "1%",
|
||||
}}
|
||||
title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`}
|
||||
>
|
||||
{event.type === "bed_time"
|
||||
? "↓"
|
||||
: event.type === "final_get_up" ||
|
||||
event.type === "night_get_up"
|
||||
? "↑"
|
||||
: event.type === "daytime_sleepiness"
|
||||
? "S"
|
||||
: ""}
|
||||
M{intakes.length > 1 ? ` ×${intakes.length}` : ""}
|
||||
</i>
|
||||
);
|
||||
})}
|
||||
{entry.intakes.map((intake) => (
|
||||
<i
|
||||
key={intake.intake_id}
|
||||
className="sleep-event sleep-medication"
|
||||
style={{
|
||||
left: `${offset(intake.taken_at, entry.night_start_date)}%`,
|
||||
width: "1%",
|
||||
}}
|
||||
title={`${t.medication}: ${intake.medication_name}`}
|
||||
>
|
||||
M
|
||||
</i>
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
{entry.sleep_quality ?? "—"} / {entry.wake_quality ?? "—"} /{" "}
|
||||
{entry.day_form ?? "—"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
))}
|
||||
</span>
|
||||
<span className="sleep-row-summary">
|
||||
<span className="sleep-row-facts">
|
||||
<span data-testid="sleep-agenda-bed-time">
|
||||
<small>{t.bed_time}</small>
|
||||
<b>{facts.bedTime ?? "—"}</b>
|
||||
</span>
|
||||
<span data-testid="sleep-agenda-final-get-up">
|
||||
<small>{t.final_get_up}</small>
|
||||
<b>{facts.finalGetUp ?? "—"}</b>
|
||||
</span>
|
||||
<span data-testid="sleep-agenda-sleep-duration">
|
||||
<small>{t.sleep}</small>
|
||||
<b>
|
||||
{facts.sleepSeconds > 0
|
||||
? formatDuration(facts.sleepSeconds)
|
||||
: "—"}
|
||||
</b>
|
||||
</span>
|
||||
<span data-testid="sleep-agenda-time-in-bed">
|
||||
<small>
|
||||
{language === "fr" ? "Temps au lit" : "Time in bed"}
|
||||
</small>
|
||||
<b>
|
||||
{facts.timeInBedSeconds === null
|
||||
? "—"
|
||||
: formatDuration(facts.timeInBedSeconds)}
|
||||
</b>
|
||||
</span>
|
||||
{facts.longAwakeCount > 0 && (
|
||||
<span data-testid="sleep-agenda-long-awake">
|
||||
<small>
|
||||
{language === "fr" ? "Réveils" : "Awakenings"}
|
||||
</small>
|
||||
<b>
|
||||
{countAndDuration(
|
||||
facts.longAwakeCount,
|
||||
facts.longAwakeSeconds,
|
||||
)}
|
||||
</b>
|
||||
</span>
|
||||
)}
|
||||
{facts.napCount > 0 && (
|
||||
<span data-testid="sleep-agenda-naps">
|
||||
<small>{t.nap}</small>
|
||||
<b>
|
||||
{countAndDuration(
|
||||
facts.napCount,
|
||||
facts.napSeconds,
|
||||
)}
|
||||
</b>
|
||||
</span>
|
||||
)}
|
||||
{facts.sleepinessCount > 0 && (
|
||||
<span data-testid="sleep-agenda-sleepiness">
|
||||
<small>{t.daytime_sleepiness}</small>
|
||||
<b>
|
||||
{facts.sleepinessCount}{" "}
|
||||
{language === "fr"
|
||||
? facts.sleepinessCount === 1
|
||||
? "occurrence"
|
||||
: "occurrences"
|
||||
: facts.sleepinessCount === 1
|
||||
? "occurrence"
|
||||
: "occurrences"}
|
||||
</b>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sleep-row-qualities">
|
||||
<span>
|
||||
<small>{t.sleep}</small>
|
||||
<b>{entry.sleep_quality ?? "—"}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{language === "fr" ? "Réveil" : "Wake"}</small>
|
||||
<b>{entry.wake_quality ?? "—"}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>{language === "fr" ? "Journée" : "Day"}</small>
|
||||
<b>{entry.day_form ?? "—"}</b>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -984,7 +1121,7 @@ export function SleepDiaryWorkspace({
|
|||
.filter((item) => item.active)
|
||||
.map((item) => (
|
||||
<option key={item.medication_id} value={item.medication_id}>
|
||||
{item.name}
|
||||
{medicationLabel(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -1156,7 +1293,7 @@ export function SleepDiaryWorkspace({
|
|||
<span>
|
||||
{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"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1223,19 +1360,26 @@ export function SleepDiaryWorkspace({
|
|||
{language === "fr" ? "Sommeil déclaré" : "Declared sleep"}
|
||||
</dt>
|
||||
<dd>
|
||||
{Math.round(
|
||||
(snapshot?.summary.sleep_duration_seconds ?? 0) / 60,
|
||||
)}{" "}
|
||||
min
|
||||
{formatDuration(snapshot?.summary.sleep_duration_seconds ?? 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{language === "fr" ? "Longs réveils" : "Long awakenings"}</dt>
|
||||
<dd>{snapshot?.summary.long_awake_count ?? 0}</dd>
|
||||
<dd>
|
||||
{countAndDuration(
|
||||
snapshot?.summary.long_awake_count ?? 0,
|
||||
snapshot?.summary.long_awake_duration_seconds ?? 0,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{language === "fr" ? "Siestes" : "Naps"}</dt>
|
||||
<dd>{snapshot?.summary.nap_count ?? 0}</dd>
|
||||
<dd>
|
||||
{countAndDuration(
|
||||
snapshot?.summary.nap_count ?? 0,
|
||||
snapshot?.summary.nap_duration_seconds ?? 0,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
|
|
|||
84
web/src/routes/sleepDiaryFacts.test.ts
Normal file
84
web/src/routes/sleepDiaryFacts.test.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
64
web/src/routes/sleepDiaryFacts.ts
Normal file
64
web/src/routes/sleepDiaryFacts.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue