fix(web): refresh sleep agenda from persisted revisions

This commit is contained in:
fy59 2026-09-21 16:19:39 +02:00
parent a98a4440ed
commit 20fe562cd8
12 changed files with 1186 additions and 140 deletions

View file

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

View file

@ -138,7 +138,7 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and 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) |

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View file

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

View file

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

View 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()

View file

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

View file

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

View file

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

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

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

View file

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