From ffcdc3efcf94d0239cc5c3517d8997fd629f795a Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 24 Sep 2026 08:18:53 +0200 Subject: [PATCH] fix(web): align sleep axis and range PDF export --- CHANGELOG.md | 3 + docs/current_state.md | 15 ++--- .../sleep_awake_selection_pdf_v1_evidence.md | 10 ++-- docs/tests.md | 5 ++ web/src/routes/SleepDiaryWorkspace.test.tsx | 49 +++++++++++++++- web/src/routes/SleepDiaryWorkspace.tsx | 58 +++++++++++++++++-- web/src/theme/app.css | 25 +++++++- 7 files changed, 146 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc75b9..4eb6544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ also refined. default. A repeated tap inside the window extends its end to 30 minutes after the latest tap, while Levé clamps it to the factual final-get-up timestamp. The policy is revisioned, undoable, and independent of BPM/RR inference. +- Aligned the Sleep agenda's hour labels with a shared 24-column visual grid + and added an inclusive night-start date range for PDF preview and export. + Detail/HR night selection remains independent from the report range. ## 0.1.5 — 2026-09-23 diff --git a/docs/current_state.md b/docs/current_state.md index 0ee513d..46360ce 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -170,7 +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. | +| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. | | Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 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` | @@ -186,10 +186,10 @@ 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 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 +presentation-only and atomically drives the detail dates, editor content, and +full-width heart-rate detail; it performs no database mutation. PDF preview +and download instead share an explicit inclusive range over night start dates, +initialized to the loaded agenda bounds and independent of detail selection. 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 @@ -200,11 +200,12 @@ 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. Agenda, selected-night graph, and PDF consume the same projection of +data. Agenda, selected-night graph, and ranged 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 +shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by +the labels and every row. 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. diff --git a/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md index 3858e35..0a1be6c 100644 --- a/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md +++ b/docs/reviews/sleep_awake_selection_pdf_v1_evidence.md @@ -12,14 +12,16 @@ 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. +and download receive the same explicit inclusive range over each entry's +`night_start_date`, and all observations are recomputed from that subset. The +range initially spans the loaded agenda period; later detail/HR selection does +not alter it. The floating hour axis is reinforced by the same 24 equal visual +columns used beneath every 18:00-to-18:00 agenda row. ## Automated validation - Web targeted suites: 34/34 passed. -- Complete Web suite: 208/208 passed. +- Complete Web suite after the range/axis follow-up: 209/209 passed. - `npm run typecheck`: passed. - `npm run build`: passed. - Embedded production bundle `meson compile -C build`: passed. diff --git a/docs/tests.md b/docs/tests.md index d7c8101..097f5b8 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1846,6 +1846,11 @@ The 30-minute awakening-policy follow-up passes the current complete inventory: agenda/HR bands and two orange vector-PDF intervals for the repaired 2026-09-23 night. +The subsequent agenda/report-range regression covers exact 18:00-to-18:00 +hour geometry, a visible 24-column row grid, inclusive start/end filtering, +identical preview/export snapshots, range summary recomputation, and the +independence of the selected HR/editor night from the PDF range. + The private Android installation was advanced non-destructively to Trainlog 0.1.5 versionCode 41 with the same debug signing identity and no uninstall or data clear. The device locked before the final visual Sleep-screen inspection, diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index 3f1e14e..c04ea4b 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -243,7 +243,7 @@ describe("SleepDiaryWorkspace", () => { ); }); - it("uses the same single active-night subset for PDF preview and export", async () => { + it("uses the same inclusive date-range subset for PDF preview and export", async () => { const first = dayAEntry(); const second = { ...dayAEntry(), @@ -261,6 +261,12 @@ describe("SleepDiaryWorkspace", () => { render(); fireEvent.click(await screen.findByTestId(`sleep-agenda-row-${second.entry_id}`)); + fireEvent.change(screen.getByLabelText("PDF du"), { + target: { value: "2026-09-18" }, + }); + fireEvent.change(screen.getByLabelText("au"), { + target: { value: "2026-09-18" }, + }); fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" })); @@ -274,6 +280,47 @@ describe("SleepDiaryWorkspace", () => { expect(api.saveSleepEntry).not.toHaveBeenCalled(); }); + it("exports every night whose start date is inside the chosen range", async () => { + const first = dayAEntry(); + const second = { + ...dayAEntry(), + entry_id: "sl_range_second", + revision_id: "slr_range_second", + night_start_date: "2026-09-19", + night_end_date: "2026-09-20", + }; + api.fetchSleepDiary.mockResolvedValue(snapshotWith([first, second])); + const build = vi.spyOn(pdfReport, "buildSleepDiaryPdf").mockReturnValue( + new Blob(["range"], { type: "application/pdf" }), + ); + vi.spyOn(pdfReport, "presentSleepDiaryPdf").mockImplementation(() => {}); + render(); + + await screen.findByTestId(`sleep-agenda-row-${second.entry_id}`); + await waitFor(() => { + expect(screen.getByLabelText("PDF du")).toHaveValue("2026-09-19"); + expect(screen.getByLabelText("au")).toHaveValue(first.night_start_date); + }); + fireEvent.change(screen.getByLabelText("PDF du"), { + target: { value: "2026-09-21" }, + }); + expect(screen.getByRole("button", { name: "Prévisualiser" })).toBeDisabled(); + expect(screen.getByText("Aucune nuit dans cette plage.")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("PDF du"), { + target: { value: "2026-09-19" }, + }); + fireEvent.change(screen.getByLabelText("au"), { + target: { value: first.night_start_date }, + }); + fireEvent.click(screen.getByRole("button", { name: "Prévisualiser" })); + + const [selection] = build.mock.calls[0]; + expect(selection.entries.map((entry) => entry.entry_id).sort()).toEqual( + [first.entry_id, second.entry_id].sort(), + ); + expect(selection.summary.nights).toBe(2); + }); + 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 b4257be..22c6713 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -70,6 +70,9 @@ const copy = { agendaEmpty: "Aucune donnée pour cette nuit.", preview: "Prévisualiser", export: "Exporter PDF", + pdfFrom: "PDF du", + pdfTo: "au", + pdfEmpty: "Aucune nuit dans cette plage.", medications: "Médicaments", addMedication: "Ajouter un médicament", medication: "Médicament", @@ -130,6 +133,9 @@ const copy = { agendaEmpty: "No data for this night.", preview: "Preview", export: "Export PDF", + pdfFrom: "PDF from", + pdfTo: "to", + pdfEmpty: "No night in this range.", medications: "Medications", addMedication: "Add a medication", medication: "Medication", @@ -685,18 +691,55 @@ 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]) + const [pdfStartDate, setPdfStartDate] = useState(""); + const [pdfEndDate, setPdfEndDate] = useState(""); + const pdfRangeInitialized = useRef(false); + useEffect(() => { + if (pdfRangeInitialized.current || agendaEntries.length === 0) return; + // WHY: opening the report controls should immediately cover the visible + // loaded period while leaving all subsequent range choices user-owned. + setPdfStartDate(agendaEntries[0].night_start_date); + setPdfEndDate(agendaEntries[agendaEntries.length - 1].night_start_date); + pdfRangeInitialized.current = true; + }, [agendaEntries]); + const pdfRangeValid = pdfStartDate !== "" && pdfEndDate !== "" && + pdfStartDate <= pdfEndDate; + const pdfEntries = pdfRangeValid + ? agendaEntries.filter( + (entry) => entry.night_start_date >= pdfStartDate && + entry.night_start_date <= pdfEndDate, + ) + : []; + // CONTRACT: preview and download share the same inclusive date-range + // selection. The range concerns the date on which each night begins; detail + // selection remains independent and continues to drive the HR/editor view. + const selectedSnapshot = snapshot && pdfEntries.length > 0 + ? sleepSnapshotSelection(snapshot, pdfEntries) : null; return (

{t.agenda}

-
+
+ +
+ {!selectedSnapshot && agendaEntries.length > 0 && ( +

{t.pdfEmpty}

+ )} {agendaEntries.length === 0 ? (

{t.agendaEmpty}

) : ( diff --git a/web/src/theme/app.css b/web/src/theme/app.css index f788767..d07c4a3 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -626,6 +626,20 @@ h2 { align-items: center; flex-wrap: wrap; } +.sleep-pdf-controls label { + display: grid; + gap: 0.2rem; + color: var(--subtext-0); + font-size: 0.72rem; +} +.sleep-pdf-controls input { + min-height: 2.25rem; +} +.sleep-pdf-range-error { + margin: 0.25rem 0 0.6rem; + color: var(--peach); + font-size: var(--font-size-sm); +} .sleep-entry-card { display: grid; gap: 1rem; @@ -789,7 +803,16 @@ h2 { .sleep-track { position: relative; height: 1.75rem; - background: var(--surface-0); + /* CONTRACT: the 24 equal columns share the exact 18:00-to-18:00 geometry + * used by the positioned events and by the hour labels above them. */ + background-color: var(--surface-0); + background-image: repeating-linear-gradient( + to right, + transparent 0, + transparent calc((100% / 24) - 1px), + color-mix(in srgb, var(--surface-2) 55%, transparent) calc((100% / 24) - 1px), + color-mix(in srgb, var(--surface-2) 55%, transparent) calc(100% / 24) + ); box-shadow: inset 0 0 0 1px var(--surface-2); border-radius: 0.35rem; }