fix(web): reuse sleep projection in agenda timeline

This commit is contained in:
fy59 2026-09-24 06:52:10 +02:00
parent 696dac82a3
commit a60e1aa056
15 changed files with 317 additions and 43 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -92,8 +92,8 @@ stream
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 658.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 696.0 522.0 Td (-) Tj ET
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 75 mg ; 22:00 \227 ve) Tj ET
0 g BT /F1 4 Tf 730.0 522.0 Td (nlafaxine \227 37,5 mg) Tj ET
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 37,5 mg ; 22:00 \227 ) Tj ET
0 g BT /F1 4 Tf 730.0 522.0 Td (venlafaxine \227 75 mg) Tj ET
0 g BT /F1 4 Tf 730.0 513.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 504.0 Td () Tj ET
0.7 G 30 458 782 40 re S
@ -124,9 +124,9 @@ stream
0.9 G 570.3333333333333 458 m 570.3333333333333 498 l S
0.9 G 591.1666666666667 458 m 591.1666666666667 498 l S
0.9 G 612 458 m 612 498 l S
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
0.75 g 205.75 469.00 104.17 18.00 re f 0 G 205.75 469.00 104.17 18.00 re S
0 g BT /F1 5 Tf 207.8 475.0 Td (SOMMEIL) Tj ET
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
0 g BT /F1 8 Tf 336.0 473.0 Td (^) Tj ET
0 g BT /F1 8 Tf 559.9 473.0 Td (S) Tj ET
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -426,6 +426,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
};
const sleep=box('sleep-agenda-event-sleep');
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
.filter(value => getComputedStyle(value).display !== 'none')
.map(value => {
const tick=value.getBoundingClientRect();
return {
@ -460,9 +461,9 @@ class BrowserSleepDiaryTest(unittest.TestCase):
self.assertAlmostEqual(
expected_x, geometry[key], delta=1.0, msg=key
)
agenda_tick_hours = (0, 6, 12, 18, 24)
agenda_tick_hours = tuple(range(0, 25, 2))
self.assertEqual(
["18:00", "00:00", "06:00", "12:00", "18:00"],
[f"{(18 + hour) % 24:02d}" for hour in agenda_tick_hours],
[tick["label"] for tick in geometry["ticks"]],
)
self.assertEqual(
@ -528,6 +529,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
response = json.loads(bidi.recv())
self.assertEqual("success", response["type"])
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
narrow_ticks = driver.execute_script(
"""
return [...document.querySelectorAll('.sleep-hour-axis > span')]
.filter(value => getComputedStyle(value).display !== 'none')
.map(value => value.textContent);
"""
)
self.assertEqual(["18", "00", "06", "12", "18"], narrow_ticks)
self.assertLessEqual(
driver.execute_script("return document.documentElement.scrollWidth"),
driver.execute_script("return document.documentElement.clientWidth"),

View file

@ -88,6 +88,8 @@ if web_assets_enabled
'../web/src/routes/SleepDiaryWorkspace.tsx',
'../web/src/routes/sleepTimelineGeometry.ts',
'../web/src/routes/sleepTimelineGeometry.test.ts',
'../web/src/routes/sleepVisualProjection.ts',
'../web/src/routes/sleepVisualProjection.test.ts',
'../web/src/report/sleepDiaryPdf.ts',
'../web/src/report/sleepDiaryPdf.test.ts',
'../web/src/routes/AnalysisPage.test.tsx',

View file

@ -11,6 +11,7 @@ import {
timelineInterval,
timelinePosition,
} from '../routes/sleepTimelineGeometry'
import { sleepDisplayRanges } from '../routes/sleepVisualProjection'
import { heartRateVariations } from './heartRateVariation'
const WIDTH = 1000
@ -96,36 +97,6 @@ function doseLabel(intake: MedicationIntake): string {
return `${intake.medication_name} · ${intake.dose_value} ${intake.dose_unit ?? ''}${quantity}`.trim()
}
interface SleepRange {
start: number
end: number
estimated: boolean
}
function sleepRanges(entry: SleepEntry): SleepRange[] {
// CONTRACT: recorded Sleep intervals always outrank visualization-only
// estimates. The fallback never mutates the entry or claims a medical
// inference; incomplete bounds deliberately produce no range.
const factual = entry.events
.filter((event) => event.type === 'sleep' && event.end_at !== null)
.map((event) => ({
start: Date.parse(event.start_at),
end: Date.parse(event.end_at as string),
estimated: false,
}))
.filter((range) => Number.isFinite(range.start) && range.end > range.start)
if (factual.length > 0) return factual
const bedTime = entry.events.find((event) => event.type === 'bed_time')
const finalGetUp = entry.events.find((event) => event.type === 'final_get_up')
if (!bedTime || !finalGetUp) return []
const start = Date.parse(bedTime.start_at) + 45 * 60 * 1000
const end = Date.parse(finalGetUp.start_at) - 10 * 60 * 1000
return Number.isFinite(start) && Number.isFinite(end) && end > start
? [{ start, end, estimated: true }]
: []
}
function markerTitle(event: SleepEvent, french: boolean): string {
const timelineEvent: HeartRateTimelineEvent = {
type: event.type,
@ -180,7 +151,7 @@ export function HeartRateTimelinePanel({
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
}, [timeline])
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
const sleep = sleepEntry ? sleepDisplayRanges(sleepEntry) : []
if (failed) {
return <section className="heart-rate-panel" role="alert">

View file

@ -640,10 +640,66 @@ describe("SleepDiaryWorkspace", () => {
);
expect(intake).toHaveStyle({ left: "18.75%" });
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([
["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"],
["12:00", "75%"], ["18:00", "100%"],
expect(ticks).toHaveLength(25);
expect(
ticks
.map((tick) => [tick.textContent, tick.style.left])
.filter((_, index) => index % 2 === 0),
).toEqual([
["18", "0%"],
["20", `${(2 / 24) * 100}%`],
["22", `${(4 / 24) * 100}%`],
["00", "25%"],
["02", `${(8 / 24) * 100}%`],
["04", `${(10 / 24) * 100}%`],
["06", "50%"],
["08", `${(14 / 24) * 100}%`],
["10", `${(16 / 24) * 100}%`],
["12", "75%"],
["14", `${(20 / 24) * 100}%`],
["16", `${(22 / 24) * 100}%`],
["18", "100%"],
]);
expect(ticks[1]).toHaveClass("sleep-hour-tick-odd");
expect(ticks[2]).toHaveClass("sleep-hour-tick-even");
expect(ticks[6]).toHaveClass("sleep-hour-tick-major");
});
it("shows the shared estimated sleep range only when factual sleep is absent", async () => {
const estimated = {
...dayAEntry(),
entry_id: "sl_estimated",
revision_id: "slr_estimated",
night_start_date: "2026-09-23",
night_end_date: "2026-09-24",
events: [
{
event_id: "bed",
type: "bed_time" as const,
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
{
event_id: "up",
type: "final_get_up" as const,
start_at: "2026-09-24T04:14:00+02:00",
end_at: null,
},
],
intakes: [],
};
api.fetchSleepDiary.mockResolvedValue(snapshotWith([estimated]));
api.fetchSleepMedications.mockResolvedValue([medication]);
render(<SleepDiaryWorkspace period="30d" language="fr" />);
const range = await screen.findByTestId("sleep-agenda-sleep-estimated");
expect(range).toHaveAttribute(
"aria-label",
"Sommeil estimé 22:50 → 04:04",
);
expect(Number.parseFloat(range.style.left)).toBeCloseTo((4.833333333 / 24) * 100, 7);
expect(Number.parseFloat(range.style.width)).toBeCloseTo((5.233333333 / 24) * 100, 7);
expect(screen.queryByTestId("sleep-agenda-event-sleep")).not.toBeInTheDocument();
});
it("isolates an empty editor night while retaining the period agenda", async () => {

View file

@ -25,9 +25,11 @@ import {
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
import { sleepEntryFacts } from "./sleepDiaryFacts";
import {
sleepAgendaTicks,
sleepTimelineInterval,
sleepTimelinePosition,
} from "./sleepTimelineGeometry";
import { sleepDisplayRanges } from "./sleepVisualProjection";
type Language = "fr" | "en";
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
@ -161,6 +163,12 @@ const copy = {
} as const;
const clock = (iso: string) => (iso ? iso.slice(11, 16) : "");
const projectedClock = (timestamp: number) =>
new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
const medicationLabel = (medication: SleepMedication) =>
medication.default_dose_value === null
? medication.name
@ -717,12 +725,22 @@ export function SleepDiaryWorkspace({
<div className="sleep-axis-row" aria-hidden="true">
<span />
<span className="sleep-hour-axis">
{[0, 6, 12, 18, 24].map((index) => (
{sleepAgendaTicks().map((tick) => (
<span
key={index}
style={{ left: `${(index / 24) * 100}%` }}
key={tick.hourOffset}
className={[
"sleep-hour-tick",
tick.hourOffset % 2 === 0
? "sleep-hour-tick-even"
: "sleep-hour-tick-odd",
tick.hourOffset % 6 === 0
? "sleep-hour-tick-major"
: "",
].join(" ")}
data-hour-offset={tick.hourOffset}
style={{ left: `${tick.position * 100}%` }}
>
{String((18 + index) % 24).padStart(2, "0")}:00
{tick.label}
</span>
))}
</span>
@ -730,6 +748,9 @@ export function SleepDiaryWorkspace({
</div>
{agendaEntries.map((entry) => {
const facts = sleepEntryFacts(entry);
const estimatedSleep = sleepDisplayRanges(entry).filter(
(range) => range.estimated,
);
const groupedIntakes = Object.entries(
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
(groups, intake) => {
@ -755,6 +776,31 @@ export function SleepDiaryWorkspace({
</small>
</strong>
<span className="sleep-track">
{estimatedSleep.map((range) => {
const geometry = sleepTimelineInterval(
new Date(range.start).toISOString(),
new Date(range.end).toISOString(),
entry.night_start_date,
entry.created_at,
);
const label = `${
language === "fr" ? "Sommeil estimé" : "Estimated sleep"
} ${projectedClock(range.start)} → ${projectedClock(range.end)}`;
return (
<i
key={`estimated-${range.start}-${range.end}`}
className="sleep-event sleep-sleep sleep-estimated"
data-testid="sleep-agenda-sleep-estimated"
role="img"
aria-label={label}
style={{
left: `${geometry.left * 100}%`,
width: `${geometry.width * 100}%`,
}}
title={label}
/>
);
})}
{entry.events.map((event) => {
const geometry = event.end_at
? sleepTimelineInterval(

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
hourlyTimelineTicks,
sleepAgendaTicks,
sleepTimelineInterval,
sleepTimelinePosition,
timelineInterval,
@ -70,4 +71,27 @@ describe("sleep timeline geometry", () => {
true,
);
});
it("derives exact one-hour and two-hour 18:00-to-18:00 ticks", () => {
const hourly = sleepAgendaTicks();
expect(hourly).toHaveLength(25);
expect(hourly[0]).toEqual({ hourOffset: 0, position: 0, label: "18" });
expect(hourly[6]).toEqual({ hourOffset: 6, position: 0.25, label: "00" });
expect(hourly[24]).toEqual({ hourOffset: 24, position: 1, label: "18" });
expect(sleepAgendaTicks(2).map((tick) => tick.label)).toEqual([
"18",
"20",
"22",
"00",
"02",
"04",
"06",
"08",
"10",
"12",
"14",
"16",
"18",
]);
});
});

View file

@ -5,6 +5,26 @@ export interface TimelineTick {
position: number;
}
export interface SleepAgendaTick {
hourOffset: number;
position: number;
label: string;
}
export function sleepAgendaTicks(stepHours = 1): SleepAgendaTick[] {
if (!Number.isInteger(stepHours) || stepHours < 1 || 24 % stepHours !== 0) {
return [];
}
return Array.from({ length: 24 / stepHours + 1 }, (_, index) => {
const hourOffset = index * stepHours;
return {
hourOffset,
position: hourOffset / 24,
label: String((18 + hourOffset) % 24).padStart(2, "0"),
};
});
}
export function timelinePosition(
timestamp: string | number,
start: string | number,

View file

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { SleepEntry } from "../api/sleepDiary";
import { sleepDisplayRanges } from "./sleepVisualProjection";
const entry = (events: SleepEntry["events"]): SleepEntry => ({
entry_id: "sl_11111111-1111-4111-8111-111111111111",
night_start_date: "2026-09-23",
night_end_date: "2026-09-24",
created_at: "2026-09-23T22:05:00+02:00",
updated_at: "2026-09-24T04:14:00+02:00",
revision_id: "slr_11111111-1111-4111-8111-111111111111",
sleep_quality: null,
wake_quality: null,
day_form: null,
treatment_and_notes: "",
events,
intakes: [],
publication_status: "ready",
});
describe("sleep visual projection", () => {
it("always prefers structured sleep ranges", () => {
const ranges = sleepDisplayRanges(
entry([
{
event_id: "bed",
type: "bed_time",
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
{
event_id: "sleep",
type: "sleep",
start_at: "2026-09-23T22:20:00+02:00",
end_at: "2026-09-24T04:00:00+02:00",
},
{
event_id: "up",
type: "final_get_up",
start_at: "2026-09-24T04:14:00+02:00",
end_at: null,
},
]),
);
expect(ranges).toEqual([
{
start: Date.parse("2026-09-23T22:20:00+02:00"),
end: Date.parse("2026-09-24T04:00:00+02:00"),
estimated: false,
},
]);
});
it("shares the conservative 22:50 to 04:04 fallback", () => {
const ranges = sleepDisplayRanges(
entry([
{
event_id: "bed",
type: "bed_time",
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
{
event_id: "up",
type: "final_get_up",
start_at: "2026-09-24T04:14:00+02:00",
end_at: null,
},
]),
);
expect(ranges).toEqual([
{
start: Date.parse("2026-09-23T22:50:00+02:00"),
end: Date.parse("2026-09-24T04:04:00+02:00"),
estimated: true,
},
]);
});
it("does not invent an estimate with a missing bound", () => {
expect(
sleepDisplayRanges(
entry([
{
event_id: "bed",
type: "bed_time",
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
]),
),
).toEqual([]);
});
});

View file

@ -0,0 +1,34 @@
import type { SleepEntry } from "../api/sleepDiary";
export interface SleepDisplayRange {
start: number;
end: number;
estimated: boolean;
}
export function sleepDisplayRanges(entry: SleepEntry): SleepDisplayRange[] {
// CONTRACT: recorded Sleep intervals always outrank visualization-only
// estimates. The fallback never mutates or enters the canonical entry, so
// agenda, HR detail, export, and synchronization cannot confuse it with a
// factual sleep event.
const factual = entry.events
.filter((event) => event.type === "sleep" && event.end_at !== null)
.map((event) => ({
start: Date.parse(event.start_at),
end: Date.parse(event.end_at as string),
estimated: false,
}))
.filter((range) => Number.isFinite(range.start) && range.end > range.start);
if (factual.length > 0) return factual;
const bedTime = entry.events.find((event) => event.type === "bed_time");
const finalGetUp = entry.events.find(
(event) => event.type === "final_get_up",
);
if (!bedTime || !finalGetUp) return [];
const start = Date.parse(bedTime.start_at) + 45 * 60 * 1000;
const end = Date.parse(finalGetUp.start_at) - 10 * 60 * 1000;
return Number.isFinite(start) && Number.isFinite(end) && end > start
? [{ start, end, estimated: true }]
: [];
}

View file

@ -714,6 +714,9 @@ h2 {
transform: translateX(-50%);
font-variant-numeric: tabular-nums;
}
.sleep-hour-tick-odd {
display: none;
}
.sleep-hour-axis > span:first-child {
transform: none;
}
@ -823,6 +826,11 @@ h2 {
transparent 3px 6px
);
}
.sleep-estimated {
opacity: 0.72;
outline: 1px dashed var(--teal);
outline-offset: -1px;
}
.sleep-long_awake {
border: 2px solid var(--peach);
background: transparent;
@ -845,6 +853,16 @@ h2 {
color: var(--crust);
font-weight: 800;
}
@media (min-width: 1600px) {
.sleep-hour-tick-odd {
display: block;
}
}
@media (max-width: 760px) {
.sleep-hour-tick-even:not(.sleep-hour-tick-major) {
display: none;
}
}
.sleep-editor-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));