diff --git a/docs/reviews/evidence/sleep-diary-alignment-firefox.png b/docs/reviews/evidence/sleep-diary-alignment-firefox.png index 93f638f..90af9a0 100644 Binary files a/docs/reviews/evidence/sleep-diary-alignment-firefox.png and b/docs/reviews/evidence/sleep-diary-alignment-firefox.png differ diff --git a/docs/reviews/evidence/sleep-diary-empty-night-firefox.png b/docs/reviews/evidence/sleep-diary-empty-night-firefox.png index 7e8e9ac..71264fb 100644 Binary files a/docs/reviews/evidence/sleep-diary-empty-night-firefox.png and b/docs/reviews/evidence/sleep-diary-empty-night-firefox.png differ diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.pdf b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf index d105a62..7b78f64 100644 --- a/docs/reviews/evidence/sleep-diary-fr-7-days.pdf +++ b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf @@ -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 diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.png b/docs/reviews/evidence/sleep-diary-fr-7-days.png index c6ef424..cfa468c 100644 Binary files a/docs/reviews/evidence/sleep-diary-fr-7-days.png and b/docs/reviews/evidence/sleep-diary-fr-7-days.png differ diff --git a/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png index f181b89..5ba084c 100644 Binary files a/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png and b/docs/reviews/evidence/sleep-diary-live-agenda-firefox.png differ diff --git a/tests/test_web_sleep_browser.py b/tests/test_web_sleep_browser.py index b7fc6b8..e64d1fe 100644 --- a/tests/test_web_sleep_browser.py +++ b/tests/test_web_sleep_browser.py @@ -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"), diff --git a/tui/meson.build b/tui/meson.build index 6f8d865..8a2879e 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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', diff --git a/web/src/components/HeartRateTimelinePanel.tsx b/web/src/components/HeartRateTimelinePanel.tsx index 9dbf222..0525738 100644 --- a/web/src/components/HeartRateTimelinePanel.tsx +++ b/web/src/components/HeartRateTimelinePanel.tsx @@ -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
diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index d4d4e25..7bfadfd 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -640,10 +640,66 @@ describe("SleepDiaryWorkspace", () => { ); expect(intake).toHaveStyle({ left: "18.75%" }); const ticks = Array.from(document.querySelectorAll(".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(); + + 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 () => { diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index a3c8902..6323373 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -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({ {agendaEntries.map((entry) => { const facts = sleepEntryFacts(entry); + const estimatedSleep = sleepDisplayRanges(entry).filter( + (range) => range.estimated, + ); const groupedIntakes = Object.entries( entry.intakes.reduce>( (groups, intake) => { @@ -755,6 +776,31 @@ export function SleepDiaryWorkspace({ + {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 ( + + ); + })} {entry.events.map((event) => { const geometry = event.end_at ? sleepTimelineInterval( diff --git a/web/src/routes/sleepTimelineGeometry.test.ts b/web/src/routes/sleepTimelineGeometry.test.ts index 2be77f6..862f03a 100644 --- a/web/src/routes/sleepTimelineGeometry.test.ts +++ b/web/src/routes/sleepTimelineGeometry.test.ts @@ -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", + ]); + }); }); diff --git a/web/src/routes/sleepTimelineGeometry.ts b/web/src/routes/sleepTimelineGeometry.ts index d0e0954..0e33818 100644 --- a/web/src/routes/sleepTimelineGeometry.ts +++ b/web/src/routes/sleepTimelineGeometry.ts @@ -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, diff --git a/web/src/routes/sleepVisualProjection.test.ts b/web/src/routes/sleepVisualProjection.test.ts new file mode 100644 index 0000000..ae014d8 --- /dev/null +++ b/web/src/routes/sleepVisualProjection.test.ts @@ -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([]); + }); +}); diff --git a/web/src/routes/sleepVisualProjection.ts b/web/src/routes/sleepVisualProjection.ts new file mode 100644 index 0000000..8889386 --- /dev/null +++ b/web/src/routes/sleepVisualProjection.ts @@ -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 }] + : []; +} diff --git a/web/src/theme/app.css b/web/src/theme/app.css index a6221c4..d1a310e 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -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));