fix(web): synchronize sleep selection and PDF projection
This commit is contained in:
parent
4cb911d5c4
commit
de4b77d59b
13 changed files with 442 additions and 121 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
docs/reviews/sleep_awake_selection_pdf_v1_evidence.md
Normal file
51
docs/reviews/sleep_awake_selection_pdf_v1_evidence.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -135,10 +135,16 @@ describe('HeartRateTimelinePanel', () => {
|
|||
events: [], guidance: null, calibration: null,
|
||||
}), { status: 200 }))
|
||||
|
||||
render(<HeartRateTimelinePanel contextId={sleepEntry().entry_id} sleepEntry={sleepEntry()} />)
|
||||
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(<HeartRateTimelinePanel contextId={entryWithAwakenings.entry_id} sleepEntry={entryWithAwakenings} />)
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 <section className="heart-rate-panel" role="alert">
|
||||
|
|
@ -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>{`${title} · ${clock(range.start)}–${clock(range.end)}`}</title>
|
||||
</rect>
|
||||
})}
|
||||
{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 <rect key={event.event_id} className="heart-rate-awake-band"
|
||||
data-testid="heart-rate-awake-band"
|
||||
|
|
@ -290,9 +293,10 @@ export function HeartRateTimelinePanel({
|
|||
return <rect key={'interval-' + index} className="heart-rate-event-band"
|
||||
x={eventX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM} />
|
||||
})}
|
||||
{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 <rect key={`sleep-interval-${event.event_id}`}
|
||||
className="heart-rate-event-band"
|
||||
|
|
@ -328,7 +332,9 @@ export function HeartRateTimelinePanel({
|
|||
y={TOP + 12 + (index % 3) * 13}>{eventLabel(event, french)}</text>
|
||||
</g>
|
||||
})}
|
||||
{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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||
|
||||
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"],
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="sleep-workspace" data-testid="sleep-workspace">
|
||||
<article className="analysis-panel analysis-wide">
|
||||
|
|
@ -690,11 +700,11 @@ export function SleepDiaryWorkspace({
|
|||
<button
|
||||
className="quiet-action"
|
||||
type="button"
|
||||
disabled={!snapshot}
|
||||
disabled={!selectedSnapshot}
|
||||
onClick={() =>
|
||||
snapshot &&
|
||||
selectedSnapshot &&
|
||||
presentSleepDiaryPdf(
|
||||
buildSleepDiaryPdf(snapshot, language),
|
||||
buildSleepDiaryPdf(selectedSnapshot, language),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
|
@ -704,11 +714,11 @@ export function SleepDiaryWorkspace({
|
|||
<button
|
||||
className="quiet-action"
|
||||
type="button"
|
||||
disabled={!snapshot}
|
||||
disabled={!selectedSnapshot}
|
||||
onClick={() =>
|
||||
snapshot &&
|
||||
selectedSnapshot &&
|
||||
presentSleepDiaryPdf(
|
||||
buildSleepDiaryPdf(snapshot, language),
|
||||
buildSleepDiaryPdf(selectedSnapshot, language),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
|
@ -748,9 +758,17 @@ export function SleepDiaryWorkspace({
|
|||
</div>
|
||||
{agendaEntries.map((entry) => {
|
||||
const facts = sleepEntryFacts(entry);
|
||||
const estimatedSleep = sleepDisplayRanges(entry).filter(
|
||||
const projection = projectSleepTimeline(entry);
|
||||
const estimatedSleep = projection.sleep.filter(
|
||||
(range) => range.estimated,
|
||||
);
|
||||
const intervalRanges = [
|
||||
...projection.sleep.filter((range) => !range.estimated),
|
||||
...projection.awake,
|
||||
...projection.otherIntervals,
|
||||
].sort(
|
||||
(left, right) => left.start - right.start || left.end - right.end,
|
||||
);
|
||||
const groupedIntakes = Object.entries(
|
||||
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
||||
(groups, intake) => {
|
||||
|
|
@ -766,7 +784,7 @@ export function SleepDiaryWorkspace({
|
|||
className={`sleep-row${selectedEntry?.entry_id === entry.entry_id ? " sleep-row-selected" : ""}`}
|
||||
data-testid={`sleep-agenda-row-${entry.entry_id}`}
|
||||
key={entry.entry_id}
|
||||
onClick={() => setSelectedEntryId(entry.entry_id)}
|
||||
onClick={() => edit(entry)}
|
||||
onDoubleClick={() => edit(entry)}
|
||||
>
|
||||
<strong>
|
||||
|
|
@ -801,47 +819,43 @@ export function SleepDiaryWorkspace({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{entry.events.map((event) => {
|
||||
const geometry = event.end_at
|
||||
? sleepTimelineInterval(
|
||||
event.start_at,
|
||||
event.end_at,
|
||||
entry.night_start_date,
|
||||
entry.created_at,
|
||||
)
|
||||
: {
|
||||
left: sleepTimelinePosition(
|
||||
event.start_at,
|
||||
entry.night_start_date,
|
||||
entry.created_at,
|
||||
),
|
||||
width: 0,
|
||||
};
|
||||
{intervalRanges.map((range) => {
|
||||
const event = range.event;
|
||||
if (!event) return null;
|
||||
const geometry = sleepTimelineInterval(
|
||||
event.start_at,
|
||||
event.end_at as string,
|
||||
entry.night_start_date,
|
||||
entry.created_at,
|
||||
);
|
||||
return (
|
||||
<i
|
||||
key={event.event_id}
|
||||
className={`sleep-event sleep-${event.type}${event.end_at ? "" : " sleep-point"}`}
|
||||
className={`sleep-event sleep-${event.type}`}
|
||||
data-testid={`sleep-agenda-event-${event.type}`}
|
||||
style={{
|
||||
left: `${geometry.left * 100}%`,
|
||||
width: event.end_at
|
||||
? `${geometry.width * 100}%`
|
||||
: undefined,
|
||||
width: `${geometry.width * 100}%`,
|
||||
}}
|
||||
title={`${t[event.type]} ${clock(event.start_at)}${event.end_at ? ` → ${clock(event.end_at)}` : ""}`}
|
||||
>
|
||||
{!event.end_at && (
|
||||
<span>
|
||||
{event.type === "bed_time"
|
||||
? "↓"
|
||||
: event.type === "final_get_up" ||
|
||||
event.type === "night_get_up"
|
||||
? "↑"
|
||||
: event.type === "daytime_sleepiness"
|
||||
? "S"
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
title={`${t[event.type]} ${clock(event.start_at)} → ${clock(event.end_at as string)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{projection.pointEvents.map((event) => {
|
||||
const left = sleepTimelinePosition(
|
||||
event.start_at,
|
||||
entry.night_start_date,
|
||||
entry.created_at,
|
||||
);
|
||||
return (
|
||||
<i key={event.event_id}
|
||||
className={`sleep-event sleep-${event.type} sleep-point`}
|
||||
data-testid={`sleep-agenda-event-${event.type}`}
|
||||
style={{ left: `${left * 100}%` }}
|
||||
title={`${t[event.type]} ${clock(event.start_at)}`}>
|
||||
<span>{event.type === "bed_time" ? "↓"
|
||||
: event.type === "final_get_up" || event.type === "night_get_up" ? "↑"
|
||||
: event.type === "daytime_sleepiness" ? "S" : ""}</span>
|
||||
</i>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { SleepEntry, SleepEvent } from "../api/sleepDiary";
|
||||
import { projectSleepTimeline } from "./sleepVisualProjection";
|
||||
|
||||
export interface SleepEntryFacts {
|
||||
bedTime: string | null;
|
||||
|
|
@ -25,6 +26,7 @@ const clock = (timestamp: string | null): string | null =>
|
|||
timestamp === null ? null : timestamp.slice(11, 16);
|
||||
|
||||
export function sleepEntryFacts(entry: SleepEntry): SleepEntryFacts {
|
||||
const projection = projectSleepTimeline(entry);
|
||||
const bedtime = entry.events.find((event) => event.type === "bed_time");
|
||||
const finalGetUp = [...entry.events]
|
||||
.reverse()
|
||||
|
|
@ -35,16 +37,12 @@ export function sleepEntryFacts(entry: SleepEntry): SleepEntryFacts {
|
|||
: Number.NaN;
|
||||
const events = (type: SleepEvent["type"]) =>
|
||||
entry.events.filter((event) => event.type === type);
|
||||
const sleep = events("sleep");
|
||||
const longAwake = events("long_awake");
|
||||
const naps = events("nap");
|
||||
return {
|
||||
bedTime: clock(bedtime?.start_at ?? null),
|
||||
finalGetUp: clock(finalGetUp?.start_at ?? null),
|
||||
sleepSeconds: sleep.reduce(
|
||||
(total, event) => total + durationSeconds(event),
|
||||
0,
|
||||
),
|
||||
sleepSeconds: projection.sleepSeconds,
|
||||
timeInBedSeconds:
|
||||
Number.isFinite(interval) && interval > 0
|
||||
? Math.floor(interval / 1000)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { SleepEntry } from "../api/sleepDiary";
|
||||
import { sleepDisplayRanges } from "./sleepVisualProjection";
|
||||
import {
|
||||
projectSleepTimeline,
|
||||
sleepDisplayRanges,
|
||||
sleepSnapshotSelection,
|
||||
} from "./sleepVisualProjection";
|
||||
|
||||
const entry = (events: SleepEntry["events"]): SleepEntry => ({
|
||||
entry_id: "sl_11111111-1111-4111-8111-111111111111",
|
||||
|
|
@ -91,4 +95,46 @@ describe("sleep visual projection", () => {
|
|||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("projects every structured awakening and subtracts their union from estimated sleep", () => {
|
||||
const projected = projectSleepTimeline(entry([
|
||||
{ event_id: "bed", type: "bed_time", start_at: "2026-09-23T22:05:00+02:00", end_at: null },
|
||||
{ event_id: "awake-a", type: "long_awake", start_at: "2026-09-24T00:00:00+02:00", end_at: "2026-09-24T00:30:00+02:00" },
|
||||
{ event_id: "awake-b", type: "long_awake", start_at: "2026-09-24T02:00:00+02:00", end_at: "2026-09-24T02:15:00+02:00" },
|
||||
{ event_id: "up", type: "final_get_up", start_at: "2026-09-24T04:14:00+02:00", end_at: null },
|
||||
]));
|
||||
expect(projected.sleep).toHaveLength(1);
|
||||
expect(projected.sleep[0].estimated).toBe(true);
|
||||
expect(projected.awake.map((range) => range.event?.event_id)).toEqual([
|
||||
"awake-a",
|
||||
"awake-b",
|
||||
]);
|
||||
expect(projected.sleepSeconds).toBe(4 * 3600 + 29 * 60);
|
||||
});
|
||||
|
||||
it("keeps exact factual sleep duration and recomputes a selected PDF subset", () => {
|
||||
const factual = entry([
|
||||
{ event_id: "sleep-a", type: "sleep", start_at: "2026-09-23T23:00:00+02:00", end_at: "2026-09-24T01:00:00+02:00" },
|
||||
{ event_id: "awake", type: "long_awake", start_at: "2026-09-24T01:00:00+02:00", end_at: "2026-09-24T01:30:00+02:00" },
|
||||
{ event_id: "sleep-b", type: "sleep", start_at: "2026-09-24T01:30:00+02:00", end_at: "2026-09-24T04:00:00+02:00" },
|
||||
]);
|
||||
const unrelated = { ...factual, entry_id: "unrelated", night_start_date: "2026-09-22" };
|
||||
const selected = sleepSnapshotSelection({
|
||||
api_version: 1,
|
||||
entries: [unrelated, factual],
|
||||
summary: {
|
||||
nights: 2, long_awake_count: 2, nap_count: 0, sleepiness_count: 0,
|
||||
intake_count: 0, sleep_duration_seconds: 99, long_awake_duration_seconds: 99,
|
||||
nap_duration_seconds: 0, average_bed_minute: 1, average_get_up_minute: 2,
|
||||
},
|
||||
}, [factual]);
|
||||
expect(projectSleepTimeline(factual).sleepSeconds).toBe(4.5 * 3600);
|
||||
expect(selected.entries).toEqual([factual]);
|
||||
expect(selected.summary).toMatchObject({
|
||||
nights: 1,
|
||||
sleep_duration_seconds: 4.5 * 3600,
|
||||
long_awake_count: 1,
|
||||
long_awake_duration_seconds: 30 * 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { SleepEntry } from "../api/sleepDiary";
|
||||
import type {
|
||||
MedicationIntake,
|
||||
SleepEntry,
|
||||
SleepEvent,
|
||||
SleepSnapshot,
|
||||
} from "../api/sleepDiary";
|
||||
|
||||
export interface SleepDisplayRange {
|
||||
start: number;
|
||||
|
|
@ -6,29 +11,160 @@ export interface SleepDisplayRange {
|
|||
estimated: boolean;
|
||||
}
|
||||
|
||||
export function sleepDisplayRanges(entry: SleepEntry): SleepDisplayRange[] {
|
||||
// CONTRACT: recorded Sleep intervals always outrank visualization-only
|
||||
// estimates. The fallback never mutates or enters the canonical entry, so
|
||||
// agenda, HR detail, export, and synchronization cannot confuse it with a
|
||||
// factual sleep event.
|
||||
const factual = entry.events
|
||||
.filter((event) => event.type === "sleep" && event.end_at !== null)
|
||||
.map((event) => ({
|
||||
start: Date.parse(event.start_at),
|
||||
end: Date.parse(event.end_at as string),
|
||||
estimated: false,
|
||||
}))
|
||||
.filter((range) => Number.isFinite(range.start) && range.end > range.start);
|
||||
if (factual.length > 0) return factual;
|
||||
export interface SleepTimelineInterval extends SleepDisplayRange {
|
||||
event: SleepEvent | null;
|
||||
kind: "sleep" | "awake" | "other";
|
||||
}
|
||||
|
||||
export interface SleepTimelineProjection {
|
||||
entry: SleepEntry;
|
||||
sleep: SleepTimelineInterval[];
|
||||
awake: SleepTimelineInterval[];
|
||||
otherIntervals: SleepTimelineInterval[];
|
||||
pointEvents: SleepEvent[];
|
||||
intakes: MedicationIntake[];
|
||||
start: number;
|
||||
end: number;
|
||||
sleepSeconds: number;
|
||||
}
|
||||
|
||||
const parsedInterval = (event: SleepEvent): [number, number] | null => {
|
||||
if (event.end_at === null) return null;
|
||||
const start = Date.parse(event.start_at);
|
||||
const end = Date.parse(event.end_at);
|
||||
return Number.isFinite(start) && Number.isFinite(end) && end > start
|
||||
? [start, end]
|
||||
: null;
|
||||
};
|
||||
|
||||
const estimatedSleep = (entry: SleepEntry): SleepTimelineInterval[] => {
|
||||
const bedTime = entry.events.find((event) => event.type === "bed_time");
|
||||
const finalGetUp = entry.events.find(
|
||||
(event) => event.type === "final_get_up",
|
||||
);
|
||||
const finalGetUp = [...entry.events]
|
||||
.reverse()
|
||||
.find((event) => event.type === "final_get_up");
|
||||
if (!bedTime || !finalGetUp) return [];
|
||||
const start = Date.parse(bedTime.start_at) + 45 * 60 * 1000;
|
||||
const end = Date.parse(finalGetUp.start_at) - 10 * 60 * 1000;
|
||||
return Number.isFinite(start) && Number.isFinite(end) && end > start
|
||||
? [{ start, end, estimated: true }]
|
||||
? [{ start, end, estimated: true, event: null, kind: "sleep" }]
|
||||
: [];
|
||||
};
|
||||
|
||||
const unionDuration = (ranges: Array<{ start: number; end: number }>) => {
|
||||
const sorted = ranges
|
||||
.filter((range) => range.end > range.start)
|
||||
.sort((left, right) => left.start - right.start || left.end - right.end);
|
||||
let total = 0;
|
||||
let start = 0;
|
||||
let end = 0;
|
||||
sorted.forEach((range, index) => {
|
||||
if (index === 0) {
|
||||
start = range.start;
|
||||
end = range.end;
|
||||
} else if (range.start <= end) {
|
||||
end = Math.max(end, range.end);
|
||||
} else {
|
||||
total += end - start;
|
||||
start = range.start;
|
||||
end = range.end;
|
||||
}
|
||||
});
|
||||
return sorted.length === 0 ? 0 : total + end - start;
|
||||
};
|
||||
|
||||
/**
|
||||
* Projects one canonical diary entry onto the single timeline consumed by the
|
||||
* agenda, HR detail, and PDF.
|
||||
*
|
||||
* CONTRACT: factual sleep outranks the conservative visual fallback; an
|
||||
* estimated range never enters persistence. Structured awakenings remain
|
||||
* distinct intervals and reduce only an estimated sleep duration.
|
||||
* INVARIANT: every returned item retains its absolute timestamp and stable
|
||||
* source object, so consumers cannot move point markers while adding bands.
|
||||
*/
|
||||
export function projectSleepTimeline(entry: SleepEntry): SleepTimelineProjection {
|
||||
const intervals = entry.events.flatMap((event): SleepTimelineInterval[] => {
|
||||
const parsed = parsedInterval(event);
|
||||
if (!parsed) return [];
|
||||
const kind = event.type === "sleep"
|
||||
? "sleep"
|
||||
: event.type === "long_awake"
|
||||
? "awake"
|
||||
: "other";
|
||||
return [{ start: parsed[0], end: parsed[1], estimated: false, event, kind }];
|
||||
});
|
||||
const factualSleep = intervals.filter((interval) => interval.kind === "sleep");
|
||||
const sleep = factualSleep.length > 0 ? factualSleep : estimatedSleep(entry);
|
||||
const awake = intervals.filter((interval) => interval.kind === "awake");
|
||||
const otherIntervals = intervals.filter((interval) => interval.kind === "other");
|
||||
const pointEvents = entry.events.filter((event) => event.end_at === null);
|
||||
const times = [
|
||||
...intervals.flatMap((interval) => [interval.start, interval.end]),
|
||||
...pointEvents.map((event) => Date.parse(event.start_at)),
|
||||
...entry.intakes.map((intake) => Date.parse(intake.taken_at)),
|
||||
].filter(Number.isFinite);
|
||||
const estimatedAwakeOverlap = sleep[0]?.estimated
|
||||
? awake.map((range) => ({
|
||||
start: Math.max(range.start, sleep[0].start),
|
||||
end: Math.min(range.end, sleep[0].end),
|
||||
}))
|
||||
: [];
|
||||
const sleepMilliseconds = factualSleep.length > 0
|
||||
? factualSleep.reduce((total, range) => total + range.end - range.start, 0)
|
||||
: sleep.reduce((total, range) => total + range.end - range.start, 0) -
|
||||
unionDuration(estimatedAwakeOverlap);
|
||||
return {
|
||||
entry,
|
||||
sleep,
|
||||
awake,
|
||||
otherIntervals,
|
||||
pointEvents,
|
||||
intakes: [...entry.intakes].sort((a, b) => a.taken_at.localeCompare(b.taken_at)),
|
||||
start: times.length > 0 ? Math.min(...times) : 0,
|
||||
end: times.length > 0 ? Math.max(...times) : 1,
|
||||
sleepSeconds: Math.max(0, Math.floor(sleepMilliseconds / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export function sleepDisplayRanges(entry: SleepEntry): SleepDisplayRange[] {
|
||||
return projectSleepTimeline(entry).sleep.map(({ start, end, estimated }) => ({
|
||||
start,
|
||||
end,
|
||||
estimated,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Builds the exact deterministic PDF subset and recomputes its observations. */
|
||||
export function sleepSnapshotSelection(
|
||||
snapshot: SleepSnapshot,
|
||||
entries: SleepEntry[],
|
||||
): SleepSnapshot {
|
||||
const projections = entries.map(projectSleepTimeline);
|
||||
const events = (type: SleepEvent["type"]) =>
|
||||
entries.flatMap((entry) => entry.events.filter((event) => event.type === type));
|
||||
const duration = (selected: SleepEvent[]) => selected.reduce((total, event) => {
|
||||
const interval = parsedInterval(event);
|
||||
return total + (interval ? Math.floor((interval[1] - interval[0]) / 1000) : 0);
|
||||
}, 0);
|
||||
return {
|
||||
api_version: snapshot.api_version,
|
||||
entries: [...entries],
|
||||
summary: {
|
||||
nights: entries.length,
|
||||
long_awake_count: events("long_awake").length,
|
||||
nap_count: events("nap").length,
|
||||
sleepiness_count: events("daytime_sleepiness").length,
|
||||
intake_count: entries.reduce((total, entry) => total + entry.intakes.length, 0),
|
||||
sleep_duration_seconds: projections.reduce(
|
||||
(total, projection) => total + projection.sleepSeconds,
|
||||
0,
|
||||
),
|
||||
long_awake_duration_seconds: duration(events("long_awake")),
|
||||
nap_duration_seconds: duration(events("nap")),
|
||||
// These averages are not rendered in V1 PDF; null prevents a selected
|
||||
// subset from inheriting aggregate values calculated for other nights.
|
||||
average_bed_minute: null,
|
||||
average_get_up_minute: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue