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({