fix(web): align sleep axis and range PDF export
This commit is contained in:
parent
23717ba6da
commit
ffcdc3efcf
7 changed files with 146 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue