From de4b77d59bd8d10c04c6981d5b936f23b333cb1c Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 24 Sep 2026 07:16:47 +0200 Subject: [PATCH] fix(web): synchronize sleep selection and PDF projection --- CHANGELOG.md | 8 + docs/current_state.md | 23 ++- .../sleep_awake_selection_pdf_v1_evidence.md | 51 +++++ docs/roadmap.md | 13 +- docs/tests.md | 9 +- .../HeartRateTimelinePanel.test.tsx | 8 +- web/src/components/HeartRateTimelinePanel.tsx | 24 ++- web/src/report/sleepDiaryPdf.ts | 63 ++++--- web/src/routes/SleepDiaryWorkspace.test.tsx | 34 ++++ web/src/routes/SleepDiaryWorkspace.tsx | 100 +++++----- web/src/routes/sleepDiaryFacts.ts | 8 +- web/src/routes/sleepVisualProjection.test.ts | 48 ++++- web/src/routes/sleepVisualProjection.ts | 174 ++++++++++++++++-- 13 files changed, 442 insertions(+), 121 deletions(-) create mode 100644 docs/reviews/sleep_awake_selection_pdf_v1_evidence.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9481b..a3565f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,14 @@ also refined. serves Web code commit `a60e1aa`; a non-mutating Firefox smoke on the real night confirms the identical 22:50–04:04 agenda/detail projection and the responsive 13/25/5-label axis densities. +- Unified the Web Sleep agenda, selected-night HR detail, and vector PDF on one + temporal projection. Every structured long-awake interval remains a distinct + band, row selection updates the detail dates and content without persistence, + and preview/download export exactly the active night with observations + recomputed for that subset. Sleep duration now consistently sums factual + sleep intervals or, only when none exist, uses the displayed Couché +45 min + to Levé -10 min estimate minus structured awakenings; the estimate is never + written to SQLite. ## 0.1.5 — 2026-09-23 diff --git a/docs/current_state.md b/docs/current_state.md index 7182c74..fdf7f14 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -170,6 +170,7 @@ parallel implementation of its rules. | 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. | | Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK | | Current operational cursor | 0.1.6 Sleep graph overlays, shared agenda estimate and denser responsive agenda axis validated and privately served from Web code commit `a60e1aa`; immutable stable v0.1.5 remains unchanged. | +| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda, selected HR detail and selected-only PDF; estimated sleep duration is consistent and remains non-persistent; Web 208/208 plus real read-only Firefox/PDF smoke passed. | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | @@ -183,9 +184,12 @@ Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. The active Sleep V2 delta keeps the Web agenda period-owned: every returned -night has one row on the shared 18:00-to-18:00 geometry, selecting a row is -presentation-only, and the selected stable Sleep entry drives the full-width -heart-rate detail. Its rise/fall overlays are descriptive relative measured +night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is +presentation-only and atomically drives the detail dates, editor content, +full-width heart-rate detail, and the single-night PDF preview/download; it +performs no database mutation. With no explicit click, the effective active +night (or the first deterministically ordered row) is the PDF fallback. Its +rise/fall overlays are descriptive relative measured variation only (five-minute trailing reference excluding the current 60-second window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They are not sleep events, sleep stages, causes, readiness, or medical inference. @@ -195,11 +199,14 @@ full-hour ticks between exact visible bounds. Medication presentation retains unit dose and quantity as separate facts. A missing declared sleep interval uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep -data. The selected-night graph and the period agenda consume the same -projection. The agenda renders an estimated interval with distinct hatching, -never substitutes it for a factual interval, and retains the exact shared -18:00-to-18:00 geometry. The overlays remain available without an HR capture -and do not fabricate BPM. +data. Agenda, selected-night graph, and PDF consume the same projection of +sleep intervals, every structured long-awake interval, point events, medication +intakes, and bounds. The agenda renders an estimated interval with distinct +hatching, never substitutes it for a factual interval, and retains the exact +shared 18:00-to-18:00 geometry. Displayed sleep duration is the sum of factual +sleep intervals or, in their absence, the displayed estimate minus overlapping +structured awakenings. The overlays remain available without an HR capture and +do not fabricate BPM. Language is device-local presentation state. Android keeps it in dedicated SharedPreferences; desktop keeps it in the XDG configuration file diff --git a/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md new file mode 100644 index 0000000..ff0e2f4 --- /dev/null +++ b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md @@ -0,0 +1,51 @@ +# Sleep awake selection and PDF V1 evidence + +`TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS` on 2026-09-24. + +The Web now derives the agenda, selected-night heart-rate overlay, and vector +PDF rows from one pure temporal projection. It retains every structured +`long_awake` interval separately from point-in-time `night_get_up` events, +medication intakes, and sleep ranges. Factual `sleep` intervals determine sleep +duration when present. Otherwise the displayed Couché +45 minutes to Levé -10 +minutes range supplies a presentation-only duration after subtracting the union +of overlapping structured awakenings; no estimate is persisted. + +One agenda row is the effective selection. A click updates the detail dates, +editor snapshot, and HR context in memory without issuing a write. PDF preview +and download receive the same one-entry snapshot, and all observations are +recomputed from that subset. Before an explicit click, the active night or the +first deterministically ordered row is the documented fallback. + +## Automated validation + +- Web targeted suites: 34/34 passed. +- Complete Web suite: 208/208 passed. +- `npm run typecheck`: passed. +- `npm run build`: passed. +- Embedded production bundle `meson compile -C build`: passed. +- The tests cover multiple awakening intervals in agenda/HR projection, exact + factual sleep duration, estimated duration with awakening subtraction, + selection dates/HR identity, no write on selection, identical preview/export + subsets, PDF interval geometry, medications, point markers, and no-HR + fallback without an invented BPM curve. + +## Read-only real-data smoke + +The rebuilt user Web service was exercised through real headless Firefox at +`http://trainlog.perf/analyse?section=sleep` without a Sleep mutation. + +- The real 2026-09-23 → 2026-09-24 row contains no factual `sleep` interval; + its displayed estimated band now reports `5 h 14 min 16 s`, not `—`. +- The real 2026-09-22 → 2026-09-23 row renders its structured awakening in + both the agenda and the no-invented-HR detail overlay. +- Selecting that row updates the detail inputs to exactly 2026-09-22 and + 2026-09-23. +- Firefox export produced one PDF page whose extracted text contains only + `22/09/2026`, `au 23/09/2026`, and `1 nuit`; it contains no 24/09 row. +- The selected-subset observation is `Sommeil déclaré : 6 h`, + `Longs réveils : 1`, `Siestes : 1`, and `Prises de médicaments : 7`. +- Raster inspection confirms the long-awake band remains visibly distinct + inside the sleep timeline and point/medication markers retain their positions. + +The change affects no database schema, synchronization artifact, frozen format, +or Android behavior. diff --git a/docs/roadmap.md b/docs/roadmap.md index 656b65e..4a0dd34 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,16 +63,17 @@ synchronized factual measurements and never connect directly to the sensor. The heart-rate path remains independent from the frozen Bluetooth Trainlog synchronization transport. -The 0.1.6 Web refinement now also layers factual Sleep intervals, awakenings, -medication intakes and complete hourly ticks on the selected night's HR chart. -Its explicitly labeled fallback estimate is presentation-only and is shared by -the period agenda, whose responsive labels retain an exact hourly -18:00-to-18:00 geometry. Android lifecycle/ACK regressions now explicitly guard +The 0.1.6 Web refinement now also projects factual Sleep intervals, every +structured awakening, point events, medication intakes, bounds, and duration +through the agenda, selected-night HR detail, and selected-night PDF. Its +explicitly labeled fallback estimate is presentation-only, subtracts overlapping +structured awakenings for displayed duration, and is shared by all three views; +the agenda retains an exact hourly 18:00-to-18:00 geometry. Android lifecycle/ACK regressions now explicitly guard pre-bed medication snapshots against restart, replay and stale ancestry. Release work remains the next separate operational step. ```text -CURRENT_OPERATIONAL_CURSOR=0.1.6 Sleep graph/agenda projection and Android medication persistence proof validated and privately deployed; stable release work remains separate. +CURRENT_OPERATIONAL_CURSOR=0.1.6 Sleep agenda/HR/PDF projection, active-night selection and Android medication persistence proof validated and privately deployed; stable release work remains separate. ``` Sleep Diary V1 stays frozen/readable while active capable peers require diff --git a/docs/tests.md b/docs/tests.md index 16337a1..ccd7d0f 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -328,6 +328,11 @@ cover exact 18:00-to-18:00 timestamp geometry, active-night isolation, empty night state, return navigation and rejection of an earlier date response that finishes late. Localized dose/unit labels, same-name medications with different usual doses and editable intake-dose prefill remain covered. +Selection regressions additionally prove that one agenda click updates both +detail dates and HR identity without a write, and that preview and download +receive the same one-night subset with recomputed observations. Projection +unit tests cover exact factual duration, fallback duration, multiple structured +awakenings, and subtraction of only their overlap from an estimated range. The heart-rate panel suite additionally covers a complete 22:05-to-04:14 night, declared and fallback-estimated sleep ranges, multiple close awakenings, final get-up, pre-bed and close medication intakes with exact dose/unit/quantity, @@ -343,7 +348,9 @@ empty night and back, then reloads and compares the persisted Agenda. The PDF suite separately asserts complete French and English vocabularies, accent-preserving WinAnsi text, human-readable duration and plural forms, localized dates, medication dose snapshots, pagination and the exact ordered -geometry of sleep / 45-minute long-awakening / sleep intervals. The same real +geometry of sleep / 45-minute long-awakening / sleep intervals. PDF rows reuse +the same projected sleep/awake/point/medication primitives as agenda and HR; +subset summaries cannot inherit facts from unselected nights. The same real Firefox scenario exports a seven-night French PDF from the embedded production bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page with `pdftoppm` for retained visual inspection. diff --git a/web/src/components/HeartRateTimelinePanel.test.tsx b/web/src/components/HeartRateTimelinePanel.test.tsx index b2fecfe..87a79da 100644 --- a/web/src/components/HeartRateTimelinePanel.test.tsx +++ b/web/src/components/HeartRateTimelinePanel.test.tsx @@ -135,10 +135,16 @@ describe('HeartRateTimelinePanel', () => { events: [], guidance: null, calibration: null, }), { status: 200 })) - render() + const entryWithAwakenings = sleepEntry() + entryWithAwakenings.events.splice(2, 0, + { event_id: 'sle_awake_a', type: 'long_awake', start_at: '2026-09-21T00:30:00+02:00', end_at: '2026-09-21T00:45:00+02:00' }, + { event_id: 'sle_awake_b', type: 'long_awake', start_at: '2026-09-21T02:30:00+02:00', end_at: '2026-09-21T02:50:00+02:00' }, + ) + render() await screen.findByTestId('heart-rate-timeline') expect(screen.getByTestId('heart-rate-sleep-factual')).toBeInTheDocument() + expect(screen.getAllByTestId('heart-rate-awake-band')).toHaveLength(2) expect(screen.getAllByTestId('heart-rate-medication-marker')).toHaveLength(2) expect(screen.getAllByTestId('heart-rate-sleep-event-night_get_up')).toHaveLength(2) expect(screen.getByTestId('heart-rate-sleep-event-final_get_up')).toBeInTheDocument() diff --git a/web/src/components/HeartRateTimelinePanel.tsx b/web/src/components/HeartRateTimelinePanel.tsx index 0525738..7d38772 100644 --- a/web/src/components/HeartRateTimelinePanel.tsx +++ b/web/src/components/HeartRateTimelinePanel.tsx @@ -11,7 +11,7 @@ import { timelineInterval, timelinePosition, } from '../routes/sleepTimelineGeometry' -import { sleepDisplayRanges } from '../routes/sleepVisualProjection' +import { projectSleepTimeline } from '../routes/sleepVisualProjection' import { heartRateVariations } from './heartRateVariation' const WIDTH = 1000 @@ -151,7 +151,8 @@ export function HeartRateTimelinePanel({ return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) } }, [timeline]) - const sleep = sleepEntry ? sleepDisplayRanges(sleepEntry) : [] + const sleepProjection = sleepEntry ? projectSleepTimeline(sleepEntry) : null + const sleep = sleepProjection?.sleep ?? [] if (failed) { return
@@ -174,7 +175,7 @@ export function HeartRateTimelinePanel({ const capture = timeline.capture const sleepEvents = sleepEntry?.events ?? [] - const sleepIntakes = sleepEntry?.intakes ?? [] + const sleepIntakes = sleepProjection?.intakes ?? [] const bedTime = sleepEvents.find((event) => event.type === 'bed_time') const finalGetUp = sleepEvents.find((event) => event.type === 'final_get_up') const finalGetUpTime = finalGetUp ? Date.parse(finalGetUp.start_at) : null @@ -254,8 +255,10 @@ export function HeartRateTimelinePanel({ {`${title} · ${clock(range.start)}–${clock(range.end)}`} })} - {visibleSleepEvents.filter((event) => event.type === 'long_awake' && event.end_at).map((event) => { - const interval = timelineInterval(event.start_at, event.end_at as string, start, end) + {(sleepProjection?.awake ?? []).filter((range) => + range.start >= start && range.start <= end && range.event).map((range) => { + const event = range.event as SleepEvent + const interval = timelineInterval(range.start, range.end, start, end) const title = markerTitle(event, french) return })} - {visibleSleepEvents.filter((event) => event.end_at && event.type !== 'sleep' && - event.type !== 'long_awake').map((event) => { - const interval = timelineInterval(event.start_at, event.end_at as string, start, end) + {(sleepProjection?.otherIntervals ?? []).filter((range) => + range.start >= start && range.start <= end && range.event).map((range) => { + const event = range.event as SleepEvent + const interval = timelineInterval(range.start, range.end, start, end) const title = markerTitle(event, french) return {eventLabel(event, french)} })} - {visibleSleepEvents.filter((event) => event.end_at === null && event.type !== 'sleep') + {(sleepProjection?.pointEvents ?? visibleSleepEvents) + .filter((event) => event.type !== 'sleep' && Date.parse(event.start_at) >= start && + Date.parse(event.start_at) <= end) .map((event, index) => { const eventX = x(event.start_at) const title = markerTitle(event, french) diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts index c237fb4..260fe25 100644 --- a/web/src/report/sleepDiaryPdf.ts +++ b/web/src/report/sleepDiaryPdf.ts @@ -4,6 +4,7 @@ import type { SleepSnapshot, } from "../api/sleepDiary"; import { formatDose, formatDuration } from "../dashboard/dashboardFormat"; +import { projectSleepTimeline } from "../routes/sleepVisualProjection"; type Language = "fr" | "en"; @@ -124,6 +125,7 @@ function row( y: number, language: Language, ) { + const projection = projectSleepTimeline(entry); const timelineX = 112; const width = 500; stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`); @@ -140,38 +142,43 @@ function row( const x = timelineX + (hour / 24) * width; stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`); } - entry.events.forEach((event) => { + [...projection.sleep, ...projection.awake, ...projection.otherIntervals] + .sort((left, right) => left.start - right.start || left.end - right.end) + .forEach((range) => { + const event = range.event; const x = timelineX + - (minute(event.start_at, entry.night_start_date) / 1440) * width; - if (event.end_at) { - // INVARIANT: every interval keeps its canonical absolute start/end on - // the shared 18:00-to-18:00 axis; long awakenings therefore interrupt - // rather than replace or merge the adjacent sleep ranges. - const eventWidth = Math.max( - 2, - timelineX + - (minute(event.end_at, entry.night_start_date) / 1440) * width - - x, - ); - const shade = - event.type === "sleep" - ? 0.75 - : event.type === "long_awake" - ? 0.5 - : event.type === "half_sleep" - ? 0.87 - : 0.93; - stream.push( - `${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`, - ); - if (eventWidth > 24) - stream.push(text(x + 2, y - 23, 5, eventLabels[language][event.type])); - } else { - stream.push(text(x, y - 25, 8, pointMarker(event.type))); + (minute(new Date(range.start).toISOString(), entry.night_start_date) / 1440) * width; + // INVARIANT: every projected interval keeps its canonical absolute + // start/end. An awakening interrupts rather than moves adjacent sleep; + // an estimated band is presentation-only and stays visually distinct. + const eventWidth = Math.max( + 2, + timelineX + + (minute(new Date(range.end).toISOString(), entry.night_start_date) / 1440) * width - + x, + ); + const shade = range.kind === "sleep" + ? range.estimated ? 0.88 : 0.75 + : range.kind === "awake" + ? 0.5 + : event?.type === "half_sleep" ? 0.87 : 0.93; + stream.push( + `${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`, + ); + if (eventWidth > 24) { + const label = event + ? eventLabels[language][event.type] + : language === "fr" ? "SOMMEIL ESTIMÉ" : "ESTIMATED SLEEP"; + stream.push(text(x + 2, y - 23, 5, label)); } + }); + projection.pointEvents.forEach((event) => { + const x = timelineX + + (minute(event.start_at, entry.night_start_date) / 1440) * width; + stream.push(text(x, y - 25, 8, pointMarker(event.type))); }); - entry.intakes.forEach((intake) => { + projection.intakes.forEach((intake) => { const x = timelineX + (minute(intake.taken_at, entry.night_start_date) / 1440) * width; diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index 7bfadfd..3f1e14e 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -12,6 +12,7 @@ import type { SleepMedication, SleepSnapshot, } from "../api/sleepDiary"; +import * as pdfReport from "../report/sleepDiaryPdf"; import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace"; const api = vi.hoisted(() => ({ @@ -228,6 +229,8 @@ describe("SleepDiaryWorkspace", () => { const secondRow = screen.getByTestId(`sleep-agenda-row-${second.entry_id}`); fireEvent.click(secondRow); expect(secondRow).toHaveClass("sleep-row-selected"); + expect(screen.getByTestId("sleep-night-start")).toHaveValue("2026-09-18"); + expect(screen.getByTestId("sleep-night-end")).toHaveValue("2026-09-19"); await waitFor(() => expect(heartRateApi.fetchHeartRateTimeline).toHaveBeenLastCalledWith( second.entry_id, @@ -240,6 +243,37 @@ describe("SleepDiaryWorkspace", () => { ); }); + it("uses the same single active-night subset for PDF preview and export", async () => { + const first = dayAEntry(); + const second = { + ...dayAEntry(), + entry_id: "sl_selected", + revision_id: "slr_selected", + night_start_date: "2026-09-18", + night_end_date: "2026-09-19", + treatment_and_notes: "Selected observation", + }; + api.fetchSleepDiary.mockResolvedValue(snapshotWith([first, second])); + const build = vi.spyOn(pdfReport, "buildSleepDiaryPdf").mockReturnValue( + new Blob(["selected"], { type: "application/pdf" }), + ); + const present = vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); + render(); + + fireEvent.click(await screen.findByTestId(`sleep-agenda-row-${second.entry_id}`)); + fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); + fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" })); + + expect(build).toHaveBeenCalledTimes(2); + for (const [selected] of build.mock.calls) { + expect(selected.entries.map((entry) => entry.entry_id)).toEqual([second.entry_id]); + expect(selected.summary.nights).toBe(1); + expect(selected.summary.sleep_duration_seconds).toBe(5 * 3600); + } + expect(present.mock.calls.map((call) => call[1])).toEqual([false, true]); + expect(api.saveSleepEntry).not.toHaveBeenCalled(); + }); + it.each([ ["bed_time", "Mise au lit", "22:30", null], ["sleep", "Sommeil", "23:00", "06:30"], diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 6323373..b4257be 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -29,7 +29,10 @@ import { sleepTimelineInterval, sleepTimelinePosition, } from "./sleepTimelineGeometry"; -import { sleepDisplayRanges } from "./sleepVisualProjection"; +import { + projectSleepTimeline, + sleepSnapshotSelection, +} from "./sleepVisualProjection"; type Language = "fr" | "en"; const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"]; @@ -334,6 +337,7 @@ export function SleepDiaryWorkspace({ const adoptEntry = (entry: SleepEntry) => { activeNightRef.current = entry.night_start_date; setActiveNight(entry.night_start_date); + setSelectedEntryId(entry.entry_id); identity.current = { entry_id: entry.entry_id, revision_id: entry.revision_id, @@ -681,6 +685,12 @@ export function SleepDiaryWorkspace({ agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ?? activeEntry ?? agendaEntries[0]; + // CONTRACT: preview and download share this exact selection object. With no + // explicit click, the effective active (or first deterministic) night is the + // documented fallback; unrelated loaded nights never leak into the PDF. + const selectedSnapshot = snapshot && selectedEntry + ? sleepSnapshotSelection(snapshot, [selectedEntry]) + : null; return (
@@ -690,11 +700,11 @@ export function SleepDiaryWorkspace({
{agendaEntries.map((entry) => { const facts = sleepEntryFacts(entry); - const estimatedSleep = sleepDisplayRanges(entry).filter( + const projection = projectSleepTimeline(entry); + const estimatedSleep = projection.sleep.filter( (range) => range.estimated, ); + const intervalRanges = [ + ...projection.sleep.filter((range) => !range.estimated), + ...projection.awake, + ...projection.otherIntervals, + ].sort( + (left, right) => left.start - right.start || left.end - right.end, + ); const groupedIntakes = Object.entries( entry.intakes.reduce>( (groups, intake) => { @@ -766,7 +784,7 @@ export function SleepDiaryWorkspace({ className={`sleep-row${selectedEntry?.entry_id === entry.entry_id ? " sleep-row-selected" : ""}`} data-testid={`sleep-agenda-row-${entry.entry_id}`} key={entry.entry_id} - onClick={() => setSelectedEntryId(entry.entry_id)} + onClick={() => edit(entry)} onDoubleClick={() => edit(entry)} > @@ -801,47 +819,43 @@ export function SleepDiaryWorkspace({ /> ); })} - {entry.events.map((event) => { - const geometry = event.end_at - ? sleepTimelineInterval( - event.start_at, - event.end_at, - entry.night_start_date, - entry.created_at, - ) - : { - left: sleepTimelinePosition( - event.start_at, - entry.night_start_date, - entry.created_at, - ), - width: 0, - }; + {intervalRanges.map((range) => { + const event = range.event; + if (!event) return null; + const geometry = sleepTimelineInterval( + event.start_at, + event.end_at as string, + entry.night_start_date, + entry.created_at, + ); return ( - {!event.end_at && ( - - {event.type === "bed_time" - ? "↓" - : event.type === "final_get_up" || - event.type === "night_get_up" - ? "↑" - : event.type === "daytime_sleepiness" - ? "S" - : ""} - - )} + title={`${t[event.type]} ${clock(event.start_at)} → ${clock(event.end_at as string)}`} + /> + ); + })} + {projection.pointEvents.map((event) => { + const left = sleepTimelinePosition( + event.start_at, + entry.night_start_date, + entry.created_at, + ); + return ( + + {event.type === "bed_time" ? "↓" + : event.type === "final_get_up" || event.type === "night_get_up" ? "↑" + : event.type === "daytime_sleepiness" ? "S" : ""} ); })} diff --git a/web/src/routes/sleepDiaryFacts.ts b/web/src/routes/sleepDiaryFacts.ts index 6eda2cd..31648e7 100644 --- a/web/src/routes/sleepDiaryFacts.ts +++ b/web/src/routes/sleepDiaryFacts.ts @@ -1,4 +1,5 @@ import type { SleepEntry, SleepEvent } from "../api/sleepDiary"; +import { projectSleepTimeline } from "./sleepVisualProjection"; export interface SleepEntryFacts { bedTime: string | null; @@ -25,6 +26,7 @@ const clock = (timestamp: string | null): string | null => timestamp === null ? null : timestamp.slice(11, 16); export function sleepEntryFacts(entry: SleepEntry): SleepEntryFacts { + const projection = projectSleepTimeline(entry); const bedtime = entry.events.find((event) => event.type === "bed_time"); const finalGetUp = [...entry.events] .reverse() @@ -35,16 +37,12 @@ export function sleepEntryFacts(entry: SleepEntry): SleepEntryFacts { : 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, - ), + sleepSeconds: projection.sleepSeconds, timeInBedSeconds: Number.isFinite(interval) && interval > 0 ? Math.floor(interval / 1000) diff --git a/web/src/routes/sleepVisualProjection.test.ts b/web/src/routes/sleepVisualProjection.test.ts index ae014d8..77ccc43 100644 --- a/web/src/routes/sleepVisualProjection.test.ts +++ b/web/src/routes/sleepVisualProjection.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { SleepEntry } from "../api/sleepDiary"; -import { sleepDisplayRanges } from "./sleepVisualProjection"; +import { + projectSleepTimeline, + sleepDisplayRanges, + sleepSnapshotSelection, +} from "./sleepVisualProjection"; const entry = (events: SleepEntry["events"]): SleepEntry => ({ entry_id: "sl_11111111-1111-4111-8111-111111111111", @@ -91,4 +95,46 @@ describe("sleep visual projection", () => { ), ).toEqual([]); }); + + it("projects every structured awakening and subtracts their union from estimated sleep", () => { + const projected = projectSleepTimeline(entry([ + { event_id: "bed", type: "bed_time", start_at: "2026-09-23T22:05:00+02:00", end_at: null }, + { event_id: "awake-a", type: "long_awake", start_at: "2026-09-24T00:00:00+02:00", end_at: "2026-09-24T00:30:00+02:00" }, + { event_id: "awake-b", type: "long_awake", start_at: "2026-09-24T02:00:00+02:00", end_at: "2026-09-24T02:15:00+02:00" }, + { event_id: "up", type: "final_get_up", start_at: "2026-09-24T04:14:00+02:00", end_at: null }, + ])); + expect(projected.sleep).toHaveLength(1); + expect(projected.sleep[0].estimated).toBe(true); + expect(projected.awake.map((range) => range.event?.event_id)).toEqual([ + "awake-a", + "awake-b", + ]); + expect(projected.sleepSeconds).toBe(4 * 3600 + 29 * 60); + }); + + it("keeps exact factual sleep duration and recomputes a selected PDF subset", () => { + const factual = entry([ + { event_id: "sleep-a", type: "sleep", start_at: "2026-09-23T23:00:00+02:00", end_at: "2026-09-24T01:00:00+02:00" }, + { event_id: "awake", type: "long_awake", start_at: "2026-09-24T01:00:00+02:00", end_at: "2026-09-24T01:30:00+02:00" }, + { event_id: "sleep-b", type: "sleep", start_at: "2026-09-24T01:30:00+02:00", end_at: "2026-09-24T04:00:00+02:00" }, + ]); + const unrelated = { ...factual, entry_id: "unrelated", night_start_date: "2026-09-22" }; + const selected = sleepSnapshotSelection({ + api_version: 1, + entries: [unrelated, factual], + summary: { + nights: 2, long_awake_count: 2, nap_count: 0, sleepiness_count: 0, + intake_count: 0, sleep_duration_seconds: 99, long_awake_duration_seconds: 99, + nap_duration_seconds: 0, average_bed_minute: 1, average_get_up_minute: 2, + }, + }, [factual]); + expect(projectSleepTimeline(factual).sleepSeconds).toBe(4.5 * 3600); + expect(selected.entries).toEqual([factual]); + expect(selected.summary).toMatchObject({ + nights: 1, + sleep_duration_seconds: 4.5 * 3600, + long_awake_count: 1, + long_awake_duration_seconds: 30 * 60, + }); + }); }); diff --git a/web/src/routes/sleepVisualProjection.ts b/web/src/routes/sleepVisualProjection.ts index 8889386..71970d9 100644 --- a/web/src/routes/sleepVisualProjection.ts +++ b/web/src/routes/sleepVisualProjection.ts @@ -1,4 +1,9 @@ -import type { SleepEntry } from "../api/sleepDiary"; +import type { + MedicationIntake, + SleepEntry, + SleepEvent, + SleepSnapshot, +} from "../api/sleepDiary"; export interface SleepDisplayRange { start: number; @@ -6,29 +11,160 @@ export interface SleepDisplayRange { 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; +export interface SleepTimelineInterval extends SleepDisplayRange { + event: SleepEvent | null; + kind: "sleep" | "awake" | "other"; +} +export interface SleepTimelineProjection { + entry: SleepEntry; + sleep: SleepTimelineInterval[]; + awake: SleepTimelineInterval[]; + otherIntervals: SleepTimelineInterval[]; + pointEvents: SleepEvent[]; + intakes: MedicationIntake[]; + start: number; + end: number; + sleepSeconds: number; +} + +const parsedInterval = (event: SleepEvent): [number, number] | null => { + if (event.end_at === null) return null; + const start = Date.parse(event.start_at); + const end = Date.parse(event.end_at); + return Number.isFinite(start) && Number.isFinite(end) && end > start + ? [start, end] + : null; +}; + +const estimatedSleep = (entry: SleepEntry): SleepTimelineInterval[] => { const bedTime = entry.events.find((event) => event.type === "bed_time"); - const finalGetUp = entry.events.find( - (event) => event.type === "final_get_up", - ); + const finalGetUp = [...entry.events] + .reverse() + .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 }] + ? [{ start, end, estimated: true, event: null, kind: "sleep" }] : []; +}; + +const unionDuration = (ranges: Array<{ start: number; end: number }>) => { + const sorted = ranges + .filter((range) => range.end > range.start) + .sort((left, right) => left.start - right.start || left.end - right.end); + let total = 0; + let start = 0; + let end = 0; + sorted.forEach((range, index) => { + if (index === 0) { + start = range.start; + end = range.end; + } else if (range.start <= end) { + end = Math.max(end, range.end); + } else { + total += end - start; + start = range.start; + end = range.end; + } + }); + return sorted.length === 0 ? 0 : total + end - start; +}; + +/** + * Projects one canonical diary entry onto the single timeline consumed by the + * agenda, HR detail, and PDF. + * + * CONTRACT: factual sleep outranks the conservative visual fallback; an + * estimated range never enters persistence. Structured awakenings remain + * distinct intervals and reduce only an estimated sleep duration. + * INVARIANT: every returned item retains its absolute timestamp and stable + * source object, so consumers cannot move point markers while adding bands. + */ +export function projectSleepTimeline(entry: SleepEntry): SleepTimelineProjection { + const intervals = entry.events.flatMap((event): SleepTimelineInterval[] => { + const parsed = parsedInterval(event); + if (!parsed) return []; + const kind = event.type === "sleep" + ? "sleep" + : event.type === "long_awake" + ? "awake" + : "other"; + return [{ start: parsed[0], end: parsed[1], estimated: false, event, kind }]; + }); + const factualSleep = intervals.filter((interval) => interval.kind === "sleep"); + const sleep = factualSleep.length > 0 ? factualSleep : estimatedSleep(entry); + const awake = intervals.filter((interval) => interval.kind === "awake"); + const otherIntervals = intervals.filter((interval) => interval.kind === "other"); + const pointEvents = entry.events.filter((event) => event.end_at === null); + const times = [ + ...intervals.flatMap((interval) => [interval.start, interval.end]), + ...pointEvents.map((event) => Date.parse(event.start_at)), + ...entry.intakes.map((intake) => Date.parse(intake.taken_at)), + ].filter(Number.isFinite); + const estimatedAwakeOverlap = sleep[0]?.estimated + ? awake.map((range) => ({ + start: Math.max(range.start, sleep[0].start), + end: Math.min(range.end, sleep[0].end), + })) + : []; + const sleepMilliseconds = factualSleep.length > 0 + ? factualSleep.reduce((total, range) => total + range.end - range.start, 0) + : sleep.reduce((total, range) => total + range.end - range.start, 0) - + unionDuration(estimatedAwakeOverlap); + return { + entry, + sleep, + awake, + otherIntervals, + pointEvents, + intakes: [...entry.intakes].sort((a, b) => a.taken_at.localeCompare(b.taken_at)), + start: times.length > 0 ? Math.min(...times) : 0, + end: times.length > 0 ? Math.max(...times) : 1, + sleepSeconds: Math.max(0, Math.floor(sleepMilliseconds / 1000)), + }; +} + +export function sleepDisplayRanges(entry: SleepEntry): SleepDisplayRange[] { + return projectSleepTimeline(entry).sleep.map(({ start, end, estimated }) => ({ + start, + end, + estimated, + })); +} + +/** Builds the exact deterministic PDF subset and recomputes its observations. */ +export function sleepSnapshotSelection( + snapshot: SleepSnapshot, + entries: SleepEntry[], +): SleepSnapshot { + const projections = entries.map(projectSleepTimeline); + const events = (type: SleepEvent["type"]) => + entries.flatMap((entry) => entry.events.filter((event) => event.type === type)); + const duration = (selected: SleepEvent[]) => selected.reduce((total, event) => { + const interval = parsedInterval(event); + return total + (interval ? Math.floor((interval[1] - interval[0]) / 1000) : 0); + }, 0); + return { + api_version: snapshot.api_version, + entries: [...entries], + summary: { + nights: entries.length, + long_awake_count: events("long_awake").length, + nap_count: events("nap").length, + sleepiness_count: events("daytime_sleepiness").length, + intake_count: entries.reduce((total, entry) => total + entry.intakes.length, 0), + sleep_duration_seconds: projections.reduce( + (total, projection) => total + projection.sleepSeconds, + 0, + ), + long_awake_duration_seconds: duration(events("long_awake")), + nap_duration_seconds: duration(events("nap")), + // These averages are not rendered in V1 PDF; null prevents a selected + // subset from inheriting aggregate values calculated for other nights. + average_bed_minute: null, + average_get_up_minute: null, + }, + }; }