fix(web): synchronize sleep selection and PDF projection

This commit is contained in:
fy59 2026-09-24 07:16:47 +02:00
parent 4cb911d5c4
commit de4b77d59b
13 changed files with 442 additions and 121 deletions

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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