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
|
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 latest tap, while Levé clamps it to the factual final-get-up timestamp.
|
||||||
The policy is revisioned, undoable, and independent of BPM/RR inference.
|
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
|
## 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. |
|
| 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 |
|
| 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. |
|
| 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. |
|
| 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 |
|
| 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` |
|
| 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
|
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
|
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,
|
presentation-only and atomically drives the detail dates, editor content, and
|
||||||
full-width heart-rate detail, and the single-night PDF preview/download; it
|
full-width heart-rate detail; it performs no database mutation. PDF preview
|
||||||
performs no database mutation. With no explicit click, the effective active
|
and download instead share an explicit inclusive range over night start dates,
|
||||||
night (or the first deterministically ordered row) is the PDF fallback. Its
|
initialized to the loaded agenda bounds and independent of detail selection. Its
|
||||||
rise/fall overlays are descriptive relative measured
|
rise/fall overlays are descriptive relative measured
|
||||||
variation only (five-minute trailing reference excluding the current 60-second
|
variation only (five-minute trailing reference excluding the current 60-second
|
||||||
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
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
|
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
|
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
|
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
|
sleep intervals, every structured long-awake interval, point events, medication
|
||||||
intakes, and bounds. The agenda renders an estimated interval with distinct
|
intakes, and bounds. The agenda renders an estimated interval with distinct
|
||||||
hatching, never substitutes it for a factual interval, and retains the exact
|
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
|
sleep intervals or, in their absence, the displayed estimate minus overlapping
|
||||||
structured awakenings. The overlays remain available without an HR capture and
|
structured awakenings. The overlays remain available without an HR capture and
|
||||||
do not fabricate BPM.
|
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,
|
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
|
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
|
and download receive the same explicit inclusive range over each entry's
|
||||||
recomputed from that subset. Before an explicit click, the active night or the
|
`night_start_date`, and all observations are recomputed from that subset. The
|
||||||
first deterministically ordered row is the documented fallback.
|
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
|
## Automated validation
|
||||||
|
|
||||||
- Web targeted suites: 34/34 passed.
|
- 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 typecheck`: passed.
|
||||||
- `npm run build`: passed.
|
- `npm run build`: passed.
|
||||||
- Embedded production bundle `meson compile -C 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
|
agenda/HR bands and two orange vector-PDF intervals for the repaired
|
||||||
2026-09-23 night.
|
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
|
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
|
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,
|
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 first = dayAEntry();
|
||||||
const second = {
|
const second = {
|
||||||
...dayAEntry(),
|
...dayAEntry(),
|
||||||
|
|
@ -261,6 +261,12 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByTestId(`sleep-agenda-row-${second.entry_id}`));
|
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: "Prévisualiser" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" }));
|
fireEvent.click(screen.getByRole("button", { name: "Exporter PDF" }));
|
||||||
|
|
||||||
|
|
@ -274,6 +280,47 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
expect(api.saveSleepEntry).not.toHaveBeenCalled();
|
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([
|
it.each([
|
||||||
["bed_time", "Mise au lit", "22:30", null],
|
["bed_time", "Mise au lit", "22:30", null],
|
||||||
["sleep", "Sommeil", "23:00", "06:30"],
|
["sleep", "Sommeil", "23:00", "06:30"],
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ const copy = {
|
||||||
agendaEmpty: "Aucune donnée pour cette nuit.",
|
agendaEmpty: "Aucune donnée pour cette nuit.",
|
||||||
preview: "Prévisualiser",
|
preview: "Prévisualiser",
|
||||||
export: "Exporter PDF",
|
export: "Exporter PDF",
|
||||||
|
pdfFrom: "PDF du",
|
||||||
|
pdfTo: "au",
|
||||||
|
pdfEmpty: "Aucune nuit dans cette plage.",
|
||||||
medications: "Médicaments",
|
medications: "Médicaments",
|
||||||
addMedication: "Ajouter un médicament",
|
addMedication: "Ajouter un médicament",
|
||||||
medication: "Médicament",
|
medication: "Médicament",
|
||||||
|
|
@ -130,6 +133,9 @@ const copy = {
|
||||||
agendaEmpty: "No data for this night.",
|
agendaEmpty: "No data for this night.",
|
||||||
preview: "Preview",
|
preview: "Preview",
|
||||||
export: "Export PDF",
|
export: "Export PDF",
|
||||||
|
pdfFrom: "PDF from",
|
||||||
|
pdfTo: "to",
|
||||||
|
pdfEmpty: "No night in this range.",
|
||||||
medications: "Medications",
|
medications: "Medications",
|
||||||
addMedication: "Add a medication",
|
addMedication: "Add a medication",
|
||||||
medication: "Medication",
|
medication: "Medication",
|
||||||
|
|
@ -685,18 +691,55 @@ export function SleepDiaryWorkspace({
|
||||||
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
|
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
|
||||||
activeEntry ??
|
activeEntry ??
|
||||||
agendaEntries[0];
|
agendaEntries[0];
|
||||||
// CONTRACT: preview and download share this exact selection object. With no
|
const [pdfStartDate, setPdfStartDate] = useState("");
|
||||||
// explicit click, the effective active (or first deterministic) night is the
|
const [pdfEndDate, setPdfEndDate] = useState("");
|
||||||
// documented fallback; unrelated loaded nights never leak into the PDF.
|
const pdfRangeInitialized = useRef(false);
|
||||||
const selectedSnapshot = snapshot && selectedEntry
|
useEffect(() => {
|
||||||
? sleepSnapshotSelection(snapshot, [selectedEntry])
|
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;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div className="sleep-workspace" data-testid="sleep-workspace">
|
<div className="sleep-workspace" data-testid="sleep-workspace">
|
||||||
<article className="analysis-panel analysis-wide">
|
<article className="analysis-panel analysis-wide">
|
||||||
<div className="sleep-heading">
|
<div className="sleep-heading">
|
||||||
<h2>{t.agenda}</h2>
|
<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
|
<button
|
||||||
className="quiet-action"
|
className="quiet-action"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -727,6 +770,9 @@ export function SleepDiaryWorkspace({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!selectedSnapshot && agendaEntries.length > 0 && (
|
||||||
|
<p className="sleep-pdf-range-error" role="status">{t.pdfEmpty}</p>
|
||||||
|
)}
|
||||||
{agendaEntries.length === 0 ? (
|
{agendaEntries.length === 0 ? (
|
||||||
<p className="analysis-empty">{t.agendaEmpty}</p>
|
<p className="analysis-empty">{t.agendaEmpty}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -626,6 +626,20 @@ h2 {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
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 {
|
.sleep-entry-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
@ -789,7 +803,16 @@ h2 {
|
||||||
.sleep-track {
|
.sleep-track {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 1.75rem;
|
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);
|
box-shadow: inset 0 0 0 1px var(--surface-2);
|
||||||
border-radius: 0.35rem;
|
border-radius: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue