fix(web): reuse sleep projection in agenda timeline
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
|
@ -92,8 +92,8 @@ stream
|
||||||
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
|
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 658.0 522.0 Td (-) Tj ET
|
||||||
0 g BT /F1 7 Tf 696.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 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 (nlafaxine \227 37,5 mg) 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 513.0 Td () Tj ET
|
||||||
0 g BT /F1 4 Tf 730.0 504.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
|
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 570.3333333333333 458 m 570.3333333333333 498 l S
|
||||||
0.9 G 591.1666666666667 458 m 591.1666666666667 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.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.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 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 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 8 Tf 559.9 473.0 Td (S) Tj ET
|
||||||
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
|
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 140 KiB |
|
|
@ -426,6 +426,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
};
|
};
|
||||||
const sleep=box('sleep-agenda-event-sleep');
|
const sleep=box('sleep-agenda-event-sleep');
|
||||||
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||||
|
.filter(value => getComputedStyle(value).display !== 'none')
|
||||||
.map(value => {
|
.map(value => {
|
||||||
const tick=value.getBoundingClientRect();
|
const tick=value.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
|
|
@ -460,9 +461,9 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
expected_x, geometry[key], delta=1.0, msg=key
|
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(
|
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"]],
|
[tick["label"] for tick in geometry["ticks"]],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
@ -528,6 +529,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
response = json.loads(bidi.recv())
|
response = json.loads(bidi.recv())
|
||||||
self.assertEqual("success", response["type"])
|
self.assertEqual("success", response["type"])
|
||||||
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
|
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(
|
self.assertLessEqual(
|
||||||
driver.execute_script("return document.documentElement.scrollWidth"),
|
driver.execute_script("return document.documentElement.scrollWidth"),
|
||||||
driver.execute_script("return document.documentElement.clientWidth"),
|
driver.execute_script("return document.documentElement.clientWidth"),
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ if web_assets_enabled
|
||||||
'../web/src/routes/SleepDiaryWorkspace.tsx',
|
'../web/src/routes/SleepDiaryWorkspace.tsx',
|
||||||
'../web/src/routes/sleepTimelineGeometry.ts',
|
'../web/src/routes/sleepTimelineGeometry.ts',
|
||||||
'../web/src/routes/sleepTimelineGeometry.test.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.ts',
|
||||||
'../web/src/report/sleepDiaryPdf.test.ts',
|
'../web/src/report/sleepDiaryPdf.test.ts',
|
||||||
'../web/src/routes/AnalysisPage.test.tsx',
|
'../web/src/routes/AnalysisPage.test.tsx',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
timelineInterval,
|
timelineInterval,
|
||||||
timelinePosition,
|
timelinePosition,
|
||||||
} from '../routes/sleepTimelineGeometry'
|
} from '../routes/sleepTimelineGeometry'
|
||||||
|
import { sleepDisplayRanges } from '../routes/sleepVisualProjection'
|
||||||
import { heartRateVariations } from './heartRateVariation'
|
import { heartRateVariations } from './heartRateVariation'
|
||||||
|
|
||||||
const WIDTH = 1000
|
const WIDTH = 1000
|
||||||
|
|
@ -96,36 +97,6 @@ function doseLabel(intake: MedicationIntake): string {
|
||||||
return `${intake.medication_name} · ${intake.dose_value} ${intake.dose_unit ?? ''}${quantity}`.trim()
|
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 {
|
function markerTitle(event: SleepEvent, french: boolean): string {
|
||||||
const timelineEvent: HeartRateTimelineEvent = {
|
const timelineEvent: HeartRateTimelineEvent = {
|
||||||
type: event.type,
|
type: event.type,
|
||||||
|
|
@ -180,7 +151,7 @@ export function HeartRateTimelinePanel({
|
||||||
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
||||||
}, [timeline])
|
}, [timeline])
|
||||||
|
|
||||||
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
|
const sleep = sleepEntry ? sleepDisplayRanges(sleepEntry) : []
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
return <section className="heart-rate-panel" role="alert">
|
return <section className="heart-rate-panel" role="alert">
|
||||||
|
|
|
||||||
|
|
@ -640,10 +640,66 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
);
|
);
|
||||||
expect(intake).toHaveStyle({ left: "18.75%" });
|
expect(intake).toHaveStyle({ left: "18.75%" });
|
||||||
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
|
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
|
||||||
expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([
|
expect(ticks).toHaveLength(25);
|
||||||
["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"],
|
expect(
|
||||||
["12:00", "75%"], ["18:00", "100%"],
|
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 () => {
|
it("isolates an empty editor night while retaining the period agenda", async () => {
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ import {
|
||||||
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
||||||
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
||||||
import {
|
import {
|
||||||
|
sleepAgendaTicks,
|
||||||
sleepTimelineInterval,
|
sleepTimelineInterval,
|
||||||
sleepTimelinePosition,
|
sleepTimelinePosition,
|
||||||
} from "./sleepTimelineGeometry";
|
} from "./sleepTimelineGeometry";
|
||||||
|
import { sleepDisplayRanges } from "./sleepVisualProjection";
|
||||||
|
|
||||||
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"];
|
||||||
|
|
@ -161,6 +163,12 @@ 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 projectedClock = (timestamp: number) =>
|
||||||
|
new Date(timestamp).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
const medicationLabel = (medication: SleepMedication) =>
|
const medicationLabel = (medication: SleepMedication) =>
|
||||||
medication.default_dose_value === null
|
medication.default_dose_value === null
|
||||||
? medication.name
|
? medication.name
|
||||||
|
|
@ -717,12 +725,22 @@ export function SleepDiaryWorkspace({
|
||||||
<div className="sleep-axis-row" aria-hidden="true">
|
<div className="sleep-axis-row" aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span className="sleep-hour-axis">
|
<span className="sleep-hour-axis">
|
||||||
{[0, 6, 12, 18, 24].map((index) => (
|
{sleepAgendaTicks().map((tick) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={tick.hourOffset}
|
||||||
style={{ left: `${(index / 24) * 100}%` }}
|
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>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -730,6 +748,9 @@ export function SleepDiaryWorkspace({
|
||||||
</div>
|
</div>
|
||||||
{agendaEntries.map((entry) => {
|
{agendaEntries.map((entry) => {
|
||||||
const facts = sleepEntryFacts(entry);
|
const facts = sleepEntryFacts(entry);
|
||||||
|
const estimatedSleep = sleepDisplayRanges(entry).filter(
|
||||||
|
(range) => range.estimated,
|
||||||
|
);
|
||||||
const groupedIntakes = Object.entries(
|
const groupedIntakes = Object.entries(
|
||||||
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
||||||
(groups, intake) => {
|
(groups, intake) => {
|
||||||
|
|
@ -755,6 +776,31 @@ export function SleepDiaryWorkspace({
|
||||||
</small>
|
</small>
|
||||||
</strong>
|
</strong>
|
||||||
<span className="sleep-track">
|
<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) => {
|
{entry.events.map((event) => {
|
||||||
const geometry = event.end_at
|
const geometry = event.end_at
|
||||||
? sleepTimelineInterval(
|
? sleepTimelineInterval(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hourlyTimelineTicks,
|
hourlyTimelineTicks,
|
||||||
|
sleepAgendaTicks,
|
||||||
sleepTimelineInterval,
|
sleepTimelineInterval,
|
||||||
sleepTimelinePosition,
|
sleepTimelinePosition,
|
||||||
timelineInterval,
|
timelineInterval,
|
||||||
|
|
@ -70,4 +71,27 @@ describe("sleep timeline geometry", () => {
|
||||||
true,
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,26 @@ export interface TimelineTick {
|
||||||
position: number;
|
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(
|
export function timelinePosition(
|
||||||
timestamp: string | number,
|
timestamp: string | number,
|
||||||
start: string | number,
|
start: string | number,
|
||||||
|
|
|
||||||
94
web/src/routes/sleepVisualProjection.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
web/src/routes/sleepVisualProjection.ts
Normal 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 }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
@ -714,6 +714,9 @@ h2 {
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.sleep-hour-tick-odd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.sleep-hour-axis > span:first-child {
|
.sleep-hour-axis > span:first-child {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
@ -823,6 +826,11 @@ h2 {
|
||||||
transparent 3px 6px
|
transparent 3px 6px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
.sleep-estimated {
|
||||||
|
opacity: 0.72;
|
||||||
|
outline: 1px dashed var(--teal);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
.sleep-long_awake {
|
.sleep-long_awake {
|
||||||
border: 2px solid var(--peach);
|
border: 2px solid var(--peach);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -845,6 +853,16 @@ h2 {
|
||||||
color: var(--crust);
|
color: var(--crust);
|
||||||
font-weight: 800;
|
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 {
|
.sleep-editor-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
|
||||||