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
|
## 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
|
- Fixed the Sleep Diary Web workspace becoming entirely blank after adding an
|
||||||
event or medication intake on the loopback-routed HTTP origin. Sleep event
|
event or medication intake on the loopback-routed HTTP origin. Sleep event
|
||||||
and intake UUIDv4 identities now use secure random bytes without depending
|
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 tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard V1 | `WEB_DASHBOARD_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 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) |
|
| 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) |
|
| 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
|
network fetch errors and unhandled rejections were all zero. The exact
|
||||||
390×844 viewport had zero document overflow. No main merge, tag or release
|
390×844 viewport had zero document overflow. No main merge, tag or release
|
||||||
was created.
|
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
|
covers default/persisted/invalid sources, ETag/token parsing, canonical-only
|
||||||
PUT, conflict translation, loading, local cancel/reset and durable Save.
|
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
|
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
||||||
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
||||||
activity map and direct totals, all compact/medium/large disclosure levels,
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<{ medication_id: string; revision_id: string }> {
|
||||||
const csrf = await mutationCsrfToken();
|
const csrf = await mutationCsrfToken();
|
||||||
const response = await fetch("/api/v1/sleep-medications", {
|
const response = await fetch("/api/v1/sleep-medications", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -295,5 +295,13 @@ export async function saveSleepMedication(
|
||||||
},
|
},
|
||||||
body: JSON.stringify(input),
|
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,
|
within,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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";
|
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
|
|
@ -62,6 +67,55 @@ const medication: SleepMedication = {
|
||||||
active: true,
|
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", () => {
|
describe("SleepDiaryWorkspace", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -185,4 +239,268 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
|
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("sleep-add-event")).toBeEnabled();
|
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,
|
buildSleepDiaryPdf,
|
||||||
presentSleepDiaryPdf,
|
presentSleepDiaryPdf,
|
||||||
} from "../report/sleepDiaryPdf";
|
} from "../report/sleepDiaryPdf";
|
||||||
|
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
||||||
|
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
||||||
|
|
||||||
type Language = "fr" | "en";
|
type Language = "fr" | "en";
|
||||||
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
||||||
|
|
@ -152,6 +154,23 @@ const copy = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const clock = (iso: string) => (iso ? iso.slice(11, 16) : "");
|
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) => {
|
const atClock = (value: string, startDate: string, endDate: string) => {
|
||||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return "";
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return "";
|
||||||
const hour = Number(value.slice(0, 2));
|
const hour = Number(value.slice(0, 2));
|
||||||
|
|
@ -264,6 +283,9 @@ export function SleepDiaryWorkspace({
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const persistence = useRef<Promise<void>>(Promise.resolve());
|
const persistence = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const mutationSequence = useRef(0);
|
||||||
|
const diaryReadSequence = useRef(0);
|
||||||
|
const medicationReadSequence = useRef(0);
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
if (period === "all") return {};
|
if (period === "all") return {};
|
||||||
const days = Number(period.slice(0, -1));
|
const days = Number(period.slice(0, -1));
|
||||||
|
|
@ -275,37 +297,48 @@ export function SleepDiaryWorkspace({
|
||||||
endDate: end.toLocaleDateString("en-CA"),
|
endDate: end.toLocaleDateString("en-CA"),
|
||||||
};
|
};
|
||||||
}, [period]);
|
}, [period]);
|
||||||
const reload = () =>
|
const readDiary = async () => {
|
||||||
Promise.all([
|
const read = ++diaryReadSequence.current;
|
||||||
fetchSleepDiary(range.startDate, range.endDate),
|
const diary = await fetchSleepDiary(range.startDate, range.endDate);
|
||||||
fetchSleepMedications(),
|
// INVARIANT: an older GET may finish after a newer mutation/reload GET;
|
||||||
])
|
// only the newest requested snapshot can replace the visible projection.
|
||||||
.then(([diary, catalog]) => {
|
if (read === diaryReadSequence.current) setSnapshot(diary);
|
||||||
setSnapshot(diary);
|
return diary;
|
||||||
setMedications(catalog);
|
};
|
||||||
if (identity.current === null) {
|
const readMedications = async () => {
|
||||||
const resume =
|
const read = ++medicationReadSequence.current;
|
||||||
diary.entries.find(
|
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) =>
|
||||||
entry.publication_status === "draft" ||
|
entry.publication_status === "draft" ||
|
||||||
entry.publication_status === "modified",
|
entry.publication_status === "modified",
|
||||||
) ?? diary.entries[0];
|
) ?? diary.entries[0]);
|
||||||
if (resume) {
|
if (resume) adoptEntry(resume);
|
||||||
identity.current = {
|
} catch {
|
||||||
entry_id: resume.entry_id,
|
setError("sleep_diary_unavailable");
|
||||||
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"));
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
@ -314,18 +347,13 @@ export function SleepDiaryWorkspace({
|
||||||
entry_id: entry.entry_id,
|
entry_id: entry.entry_id,
|
||||||
revision_id: entry.revision_id,
|
revision_id: entry.revision_id,
|
||||||
};
|
};
|
||||||
setPublicationStatus(entry.publication_status);
|
adoptEntry(entry);
|
||||||
setDraft({
|
|
||||||
...entry,
|
|
||||||
expected_revision: entry.revision_id,
|
|
||||||
events: entry.events.map((event) => ({ ...event })),
|
|
||||||
intakes: entry.intakes.map((intake) => ({ ...intake })),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const persist = (next: SleepEntryInput) => {
|
const persist = (next: SleepEntryInput) => {
|
||||||
|
const sequence = ++mutationSequence.current;
|
||||||
setSaveState("saving");
|
setSaveState("saving");
|
||||||
persistence.current = persistence.current
|
persistence.current = persistence.current.then(async () => {
|
||||||
.then(async () => {
|
try {
|
||||||
const current = identity.current;
|
const current = identity.current;
|
||||||
const result = await saveSleepEntry({
|
const result = await saveSleepEntry({
|
||||||
...next,
|
...next,
|
||||||
|
|
@ -334,25 +362,42 @@ export function SleepDiaryWorkspace({
|
||||||
updated_at: sleepTimestamp(),
|
updated_at: sleepTimestamp(),
|
||||||
});
|
});
|
||||||
identity.current = result;
|
identity.current = result;
|
||||||
setPublicationStatus((status) =>
|
const diary = await readDiary();
|
||||||
status === "synchronized" || status === "modified"
|
const persisted = diary.entries.find(
|
||||||
? "modified"
|
(entry) =>
|
||||||
: "draft",
|
entry.entry_id === result.entry_id &&
|
||||||
|
entry.revision_id === result.revision_id,
|
||||||
);
|
);
|
||||||
|
if (!persisted) throw new TypeError("sleep_diary_revision_unavailable");
|
||||||
|
if (sequence === mutationSequence.current) {
|
||||||
|
adoptEntry(persisted);
|
||||||
|
setError("");
|
||||||
setSaveState("saved");
|
setSaveState("saved");
|
||||||
setDraft((value) => ({
|
}
|
||||||
...value,
|
} catch (reason: unknown) {
|
||||||
entry_id: result.entry_id,
|
if (sequence !== mutationSequence.current) return;
|
||||||
expected_revision: result.revision_id,
|
try {
|
||||||
}));
|
const diary = await readDiary();
|
||||||
})
|
const current = identity.current;
|
||||||
.catch((reason: unknown) => {
|
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");
|
setSaveState("error");
|
||||||
setError(
|
setError(
|
||||||
reason instanceof Error
|
reason instanceof Error
|
||||||
? reason.message
|
? reason.message
|
||||||
: "sleep_diary_mutation_failed",
|
: "sleep_diary_mutation_failed",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const mutate = (transform: (current: SleepEntryInput) => SleepEntryInput) =>
|
const mutate = (transform: (current: SleepEntryInput) => SleepEntryInput) =>
|
||||||
|
|
@ -444,11 +489,10 @@ export function SleepDiaryWorkspace({
|
||||||
const addMedication = async () => {
|
const addMedication = async () => {
|
||||||
if (!medName.trim()) return;
|
if (!medName.trim()) return;
|
||||||
const now = sleepTimestamp();
|
const now = sleepTimestamp();
|
||||||
const savedName = medName.trim();
|
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await saveSleepMedication({
|
const result = await saveSleepMedication({
|
||||||
medication_id: editingMedication?.medication_id ?? "",
|
medication_id: editingMedication?.medication_id ?? "",
|
||||||
expected_revision: editingMedication?.revision_id ?? null,
|
expected_revision: editingMedication?.revision_id ?? null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|
@ -460,12 +504,10 @@ export function SleepDiaryWorkspace({
|
||||||
note: editingMedication?.note ?? "",
|
note: editingMedication?.note ?? "",
|
||||||
active: editingMedication?.active ?? true,
|
active: editingMedication?.active ?? true,
|
||||||
});
|
});
|
||||||
const catalog = await fetchSleepMedications();
|
const catalog = await readMedications();
|
||||||
setMedications(catalog);
|
const saved = catalog.find(
|
||||||
const saved =
|
(item) => item.medication_id === result.medication_id,
|
||||||
catalog.find(
|
);
|
||||||
(item) => item.medication_id === editingMedication?.medication_id,
|
|
||||||
) ?? catalog.find((item) => item.name === savedName);
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
setIntakeMedication(saved.medication_id);
|
setIntakeMedication(saved.medication_id);
|
||||||
setIntakeDose(saved.default_dose_value?.toString() ?? "");
|
setIntakeDose(saved.default_dose_value?.toString() ?? "");
|
||||||
|
|
@ -606,10 +648,22 @@ export function SleepDiaryWorkspace({
|
||||||
<span key={index}>{(18 + index) % 24}</span>
|
<span key={index}>{(18 + index) % 24}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{entries.map((entry) => (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sleep-row"
|
className="sleep-row"
|
||||||
|
data-testid={`sleep-agenda-row-${entry.entry_id}`}
|
||||||
key={entry.entry_id}
|
key={entry.entry_id}
|
||||||
onClick={() => edit(entry)}
|
onClick={() => edit(entry)}
|
||||||
>
|
>
|
||||||
|
|
@ -628,15 +682,17 @@ export function SleepDiaryWorkspace({
|
||||||
const width = event.end_at
|
const width = event.end_at
|
||||||
? Math.max(
|
? Math.max(
|
||||||
1,
|
1,
|
||||||
offset(event.end_at, entry.night_start_date) - left,
|
offset(event.end_at, entry.night_start_date) -
|
||||||
|
left,
|
||||||
)
|
)
|
||||||
: 1;
|
: 1;
|
||||||
return (
|
return (
|
||||||
<i
|
<i
|
||||||
key={event.event_id}
|
key={event.event_id}
|
||||||
className={`sleep-event sleep-${event.type}`}
|
className={`sleep-event sleep-${event.type}`}
|
||||||
|
data-testid={`sleep-agenda-event-${event.type}`}
|
||||||
style={{ left: `${left}%`, width: `${width}%` }}
|
style={{ left: `${left}%`, width: `${width}%` }}
|
||||||
title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ""}`}
|
title={`${t[event.type]} ${clock(event.start_at)}${event.end_at ? ` → ${clock(event.end_at)}` : ""}`}
|
||||||
>
|
>
|
||||||
{event.type === "bed_time"
|
{event.type === "bed_time"
|
||||||
? "↓"
|
? "↓"
|
||||||
|
|
@ -649,26 +705,107 @@ export function SleepDiaryWorkspace({
|
||||||
</i>
|
</i>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{entry.intakes.map((intake) => (
|
{groupedIntakes.map(([takenAt, intakes]) => (
|
||||||
<i
|
<i
|
||||||
key={intake.intake_id}
|
key={takenAt}
|
||||||
className="sleep-event sleep-medication"
|
className="sleep-event sleep-medication"
|
||||||
|
data-testid="sleep-agenda-medication"
|
||||||
style={{
|
style={{
|
||||||
left: `${offset(intake.taken_at, entry.night_start_date)}%`,
|
left: `${offset(takenAt, entry.night_start_date)}%`,
|
||||||
width: "1%",
|
width: "1%",
|
||||||
}}
|
}}
|
||||||
title={`${t.medication}: ${intake.medication_name}`}
|
title={`${clock(takenAt)}\n${intakes.map(intakeLabel).join("\n")}`}
|
||||||
>
|
>
|
||||||
M
|
M{intakes.length > 1 ? ` ×${intakes.length}` : ""}
|
||||||
</i>
|
</i>
|
||||||
))}
|
))}
|
||||||
</span>
|
</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>
|
<span>
|
||||||
{entry.sleep_quality ?? "—"} / {entry.wake_quality ?? "—"} /{" "}
|
<small>{t.sleep}</small>
|
||||||
{entry.day_form ?? "—"}
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -984,7 +1121,7 @@ export function SleepDiaryWorkspace({
|
||||||
.filter((item) => item.active)
|
.filter((item) => item.active)
|
||||||
.map((item) => (
|
.map((item) => (
|
||||||
<option key={item.medication_id} value={item.medication_id}>
|
<option key={item.medication_id} value={item.medication_id}>
|
||||||
{item.name}
|
{medicationLabel(item)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -1156,7 +1293,7 @@ export function SleepDiaryWorkspace({
|
||||||
<span>
|
<span>
|
||||||
{item.default_dose_value === null
|
{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"}
|
{language === "fr" ? "habituel" : "usual"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1223,19 +1360,26 @@ export function SleepDiaryWorkspace({
|
||||||
{language === "fr" ? "Sommeil déclaré" : "Declared sleep"}
|
{language === "fr" ? "Sommeil déclaré" : "Declared sleep"}
|
||||||
</dt>
|
</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{Math.round(
|
{formatDuration(snapshot?.summary.sleep_duration_seconds ?? 0)}
|
||||||
(snapshot?.summary.sleep_duration_seconds ?? 0) / 60,
|
|
||||||
)}{" "}
|
|
||||||
min
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{language === "fr" ? "Longs réveils" : "Long awakenings"}</dt>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt>{language === "fr" ? "Siestes" : "Naps"}</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</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%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.sleep-agenda {
|
.sleep-agenda {
|
||||||
min-width: 900px;
|
min-width: 1120px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.sleep-hours {
|
.sleep-hours {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 13rem repeat(25, 1fr) 9rem;
|
grid-template-columns: 12rem repeat(25, 1fr) 25rem;
|
||||||
color: var(--subtext-0);
|
color: var(--subtext-0);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
@ -694,7 +694,7 @@ h2 {
|
||||||
}
|
}
|
||||||
.sleep-row {
|
.sleep-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 13rem 1fr 9rem;
|
grid-template-columns: 12rem minmax(28rem, 1fr) 25rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
|
|
@ -717,6 +717,40 @@ h2 {
|
||||||
color: var(--subtext-0);
|
color: var(--subtext-0);
|
||||||
font-weight: 500;
|
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 {
|
.sleep-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 1.75rem;
|
height: 1.75rem;
|
||||||
|
|
@ -754,7 +788,14 @@ h2 {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.sleep-medication {
|
.sleep-medication {
|
||||||
|
min-width: 1.35rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
|
color: var(--crust);
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.sleep-editor-columns {
|
.sleep-editor-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -869,10 +910,9 @@ h2 {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
.sleep-timeline-editor .sleep-event-list li {
|
.sleep-timeline-editor .sleep-event-list li {
|
||||||
grid-template-columns: 6.5rem minmax(8rem, 1.4fr) repeat(
|
grid-template-columns:
|
||||||
2,
|
6.5rem minmax(8rem, 1.4fr) repeat(2, minmax(6rem, 0.75fr))
|
||||||
minmax(6rem, 0.75fr)
|
auto auto;
|
||||||
) auto auto;
|
|
||||||
}
|
}
|
||||||
.sleep-timeline-editor time {
|
.sleep-timeline-editor time {
|
||||||
color: var(--lavender);
|
color: var(--lavender);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue