fix(web): align sleep axis and range PDF export

This commit is contained in:
fy59 2026-09-24 08:18:53 +02:00
parent 23717ba6da
commit ffcdc3efcf
7 changed files with 146 additions and 19 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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,

View file

@ -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(<SleepDiaryWorkspace period="30d" language="fr" />);
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(<SleepDiaryWorkspace period="30d" language="fr" />);
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"],

View file

@ -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 (
<div className="sleep-workspace" data-testid="sleep-workspace">
<article className="analysis-panel analysis-wide">
<div className="sleep-heading">
<h2>{t.agenda}</h2>
<div>
<div className="sleep-pdf-controls">
<label>
<span>{t.pdfFrom}</span>
<input
aria-label={t.pdfFrom}
type="date"
value={pdfStartDate}
onChange={(event) => setPdfStartDate(event.target.value)}
/>
</label>
<label>
<span>{t.pdfTo}</span>
<input
aria-label={t.pdfTo}
type="date"
value={pdfEndDate}
onChange={(event) => setPdfEndDate(event.target.value)}
/>
</label>
<button
className="quiet-action"
type="button"
@ -727,6 +770,9 @@ export function SleepDiaryWorkspace({
</button>
</div>
</div>
{!selectedSnapshot && agendaEntries.length > 0 && (
<p className="sleep-pdf-range-error" role="status">{t.pdfEmpty}</p>
)}
{agendaEntries.length === 0 ? (
<p className="analysis-empty">{t.agendaEmpty}</p>
) : (

View file

@ -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;
}