Compare commits

...

3 commits

21 changed files with 441 additions and 63 deletions

View file

@ -45,6 +45,20 @@ also refined.
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
real HR night and the latest real night containing medications from code
commit `2ce458d`.
- Reused that same read-only fallback projection in the multi-night Sleep
agenda, with a visibly estimated hatched interval and no Sleep write. The
agenda now derives an exact 18:00-to-18:00 hourly grid, presents at least
two-hour labels on ordinary desktop widths, expands to every hour when wide,
and reduces to six-hour anchors on narrow screens. A read-only real-device
investigation of the 2026-09-23 night found no medication intake in any
retained local revision and established that later synchronization only
published the already-empty current revision. Android regression coverage
now proves two pre-bed intakes, including quantity two, through repeated
repository reopen, bedtime, wake, final get-up, generation, desktop ACK,
replay, stale-ancestor input and a subsequent revision. The private service
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.
## 0.1.5 — 2026-09-23

View file

@ -92,16 +92,55 @@ class SyncGenerationServiceTest {
active = true,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val pendingRoot =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-23",
nightEndDate = "2026-09-24",
createdAt = "2026-09-23T22:00:00+02:00",
updatedAt = "2026-09-23T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
intakes = emptyList(),
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val preBed =
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(pendingRoot.entryId, preBed.receipt.entryId)
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:21:00+02:00",
) as TrainlogRepository.SleepQuickActionResult.Applied
source.inSyncGenerationTransaction { db ->
assertEquals(
2,
db.rawQuery(
"SELECT COUNT(*) FROM sleep_medication_intakes WHERE revision_id=" +
"(SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?)",
arrayOf(preBed.receipt.entryId),
).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
},
)
}
source.close()
source = TrainlogRepository(context, sourceName)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val bed =
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 61,
@ -120,6 +159,9 @@ class SyncGenerationServiceTest {
source.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
@ -131,6 +173,8 @@ class SyncGenerationServiceTest {
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(source.listSessions().isEmpty())
val night = source.listSleepDiary().single()
@ -140,7 +184,7 @@ class SyncGenerationServiceTest {
.getJSONObject(0)
assertEquals(night.entryId, capture.getString("context_id"))
assertEquals(2, capture.getJSONArray("samples").length())
assertEquals(2, night.intakes.single().quantity)
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
@ -154,9 +198,12 @@ class SyncGenerationServiceTest {
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
assertEquals(night.entryId, publishedNight.getString("entry_id"))
assertEquals(2, publishedNight.getJSONArray("intakes").length())
assertEquals(
2,
publishedNight.getJSONArray("intakes").getJSONObject(0).getInt("quantity"),
listOf(1, 2),
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
}.sorted(),
)
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
@ -193,13 +240,13 @@ class SyncGenerationServiceTest {
assertEquals(
// The desktop fixture owns one reserved calibration session;
// the empty Android history must not add a workout.
"1|1|2|2|2|1",
"1|2|2,1|2|2|1",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
"(SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
"(SELECT group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM sessions);",
@ -207,6 +254,19 @@ class SyncGenerationServiceTest {
.trim(),
)
val staleWithoutIntakes = JSONObject(source.buildSleepDiaryV1Json())
val staleEntry = staleWithoutIntakes.getJSONArray("entries").getJSONObject(0)
staleEntry.put("revision_id", pendingRoot.revisionId)
staleEntry.put("parent_revision_id", JSONObject.NULL)
staleEntry.put("ancestry", org.json.JSONArray().put(pendingRoot.revisionId))
staleEntry.put("updated_at", "2026-09-23T22:00:00+02:00")
staleEntry.put("events", org.json.JSONArray())
staleEntry.put("intakes", org.json.JSONArray())
val staleResult = source.applySleepDiaryV1Json(staleWithoutIntakes.toString())
assertTrue(staleResult is TrainlogRepository.SleepDiaryImportResult.Applied)
assertEquals(1, (staleResult as TrainlogRepository.SleepDiaryImportResult.Applied).unchanged)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val revised =
source.saveSleepDiary(
SleepDiaryDraft(
@ -255,6 +315,7 @@ class SyncGenerationServiceTest {
)
.trim(),
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
assertTrue(source.listSessions().isEmpty())
} finally {
source.close()

View file

@ -34,6 +34,14 @@ successful intake; the per-unit dose and unit remain independent structured
facts. Pending entries persist through restart and can synchronize without
inventing bedtime.
The persistence regression begins from an empty pending revision, records two
pre-bed intakes (one with quantity two), and reopens the repository after
intake, Couché, Réveil and Levé. It then proves the same stable entry and intake
snapshots through generation, desktop import/ACK, exact replay, rejection of a
stale intake-free ancestor and a later current revision. This is the executable
contract for lifecycle and synchronization retention; it does not infer facts
that are absent from a device database.
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
no capture. After Couché, the first fresh real measurement creates or resumes
one `context_kind=sleep` capture for that entry, with `started_at` equal to the

View file

@ -169,7 +169,7 @@ parallel implementation of its rules.
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
| 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 and post-review Web corrections implemented, fully validated, and served by the private `trainlog.perf` service from development code commit `2ce458d`; 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. |
| 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` |
@ -195,8 +195,11 @@ 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 overlays remain available without an HR capture and do not fabricate
BPM.
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.
Language is device-local presentation state. Android keeps it in dedicated
SharedPreferences; desktop keeps it in the XDG configuration file
@ -341,9 +344,12 @@ cluster. Program progress uses the shared success color while retaining its
factual completed/total text. Responsive charts preserve their viewBox aspect
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
partial measured coverage instead of deriving time from sets or repetitions.
The shared Sleep agenda owns one label/track/facts grid. Its five clock labels
and every event, interval and medication marker use the same coordinates:
18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00 at 100%.
The shared Sleep agenda owns one label/track/facts grid. Its 25 exact hourly
positions and every event, interval and medication marker use the same
coordinates: 18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00
at 100%. Ordinary desktop widths show the 13 two-hour labels, wide layouts show
all hours, and narrow layouts retain the five six-hour anchors without changing
the underlying geometry.
The Equipment route projects supplied and custom definitions with exercise,
history, preparation and Program reference counts. Its guarded merge accepts a

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -92,8 +92,8 @@ stream
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 658.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 696.0 522.0 Td (-) Tj ET
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 75 mg ; 22:00 \227 ve) Tj ET
0 g BT /F1 4 Tf 730.0 522.0 Td (nlafaxine \227 37,5 mg) Tj ET
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 37,5 mg ; 22:00 \227 ) Tj ET
0 g BT /F1 4 Tf 730.0 522.0 Td (venlafaxine \227 75 mg) Tj ET
0 g BT /F1 4 Tf 730.0 513.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 504.0 Td () Tj ET
0.7 G 30 458 782 40 re S
@ -124,9 +124,9 @@ stream
0.9 G 570.3333333333333 458 m 570.3333333333333 498 l S
0.9 G 591.1666666666667 458 m 591.1666666666667 498 l S
0.9 G 612 458 m 612 498 l S
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
0.75 g 205.75 469.00 104.17 18.00 re f 0 G 205.75 469.00 104.17 18.00 re S
0 g BT /F1 5 Tf 207.8 475.0 Td (SOMMEIL) Tj ET
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
0 g BT /F1 8 Tf 336.0 473.0 Td (^) Tj ET
0 g BT /F1 8 Tf 559.9 473.0 Td (S) Tj ET
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -65,11 +65,14 @@ 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; release work
remains the next separate operational step.
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
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 overlays and post-review Web corrections validated and privately deployed; stable release work remains separate.
CURRENT_OPERATIONAL_CURSOR=0.1.6 Sleep graph/agenda projection 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

@ -25,27 +25,39 @@ still conflicts. The equipment client test removes `crypto.randomUUID`, uses
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
request; native merge tests retain their transaction/rollback coverage.
The real Sleep Bluetooth recovery regression starts with no Android session,
records a pre-bed medication with `quantity=2`, Couché, two BPM/RR samples
The real Sleep Bluetooth recovery regression starts with no Android session
and an empty pending Sleep revision, records two pre-bed medication intakes
(including `quantity=2`), reopens the repository after each meaningful
lifecycle transition, then records Couché, two BPM/RR samples
separated by Réveil, and Levé. It captures and publishes the production V2
Sleep and V1 heart-rate artifacts, imports them with the desktop consumer,
accepts and replays the ACK without duplication, then admits a second
Sleep-only revision. Separate tests prove connection-arrival auto-request
Sleep-only revision. It also applies the original intake-free ancestor after
ACK and proves that causal reconciliation cannot erase the current intakes.
Separate tests prove connection-arrival auto-request
policy with its cooldown, cross-run reuse of an unacknowledged immutable
generation and recognition of its original-run exact generation/digest ACK,
ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry,
service-installer upgrade behavior, and ACK-recovery progress frames over the
Bluetooth-selected orchestrator path.
UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, persisted
UI regressions assert the Sleep agenda's 25 exact hourly positions, 13 visible
two-hour desktop labels and five narrow six-hour anchors, including the exact
0/25/50/75/100% coordinates. They also prove that both Sleep views reuse the
same presentation-only Couché +45 minutes to Levé -10 minutes estimate, that a
factual sleep interval wins, and that a missing bound produces no estimate.
Other UI regressions cover persisted
Dashboard sizing and reset behavior, English preference persistence across a
remount, centralized Dashboard English labels, non-stretched chart aspect
ratios, and distinct missing/partial exercise-timing messages.
The closeout run passes Web **31 files / 196 tests**, TypeScript typecheck and
The closeout run passes Web **32 files / 205 tests**, TypeScript typecheck and
the production build; native Meson **111/111**; Android `testDebugUnitTest` and
`assembleDebug`; JSON/import validators; changed-C formatting; and diff checks.
The restarted private service serves the rebuilt executable byte-for-byte from
the development checkout and reports healthy Trainlog `0.1.6`.
the development checkout and reports healthy Trainlog `0.1.6`. A non-mutating
Firefox smoke on the real 2026-09-23 night confirms the agenda and detail both
show 22:50–04:04 as estimated, with 13 ordinary-desktop labels, 25 wide labels
and five narrow anchors.
Sleep Diary coverage includes schema creation/migration, midnight and DST
offsets, revision conflict, logical deletion/reopen, companion replay and

View file

@ -426,6 +426,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
};
const sleep=box('sleep-agenda-event-sleep');
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
.filter(value => getComputedStyle(value).display !== 'none')
.map(value => {
const tick=value.getBoundingClientRect();
return {
@ -460,9 +461,9 @@ class BrowserSleepDiaryTest(unittest.TestCase):
self.assertAlmostEqual(
expected_x, geometry[key], delta=1.0, msg=key
)
agenda_tick_hours = (0, 6, 12, 18, 24)
agenda_tick_hours = tuple(range(0, 25, 2))
self.assertEqual(
["18:00", "00:00", "06:00", "12:00", "18:00"],
[f"{(18 + hour) % 24:02d}" for hour in agenda_tick_hours],
[tick["label"] for tick in geometry["ticks"]],
)
self.assertEqual(
@ -528,6 +529,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
response = json.loads(bidi.recv())
self.assertEqual("success", response["type"])
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
narrow_ticks = driver.execute_script(
"""
return [...document.querySelectorAll('.sleep-hour-axis > span')]
.filter(value => getComputedStyle(value).display !== 'none')
.map(value => value.textContent);
"""
)
self.assertEqual(["18", "00", "06", "12", "18"], narrow_ticks)
self.assertLessEqual(
driver.execute_script("return document.documentElement.scrollWidth"),
driver.execute_script("return document.documentElement.clientWidth"),

View file

@ -88,6 +88,8 @@ if web_assets_enabled
'../web/src/routes/SleepDiaryWorkspace.tsx',
'../web/src/routes/sleepTimelineGeometry.ts',
'../web/src/routes/sleepTimelineGeometry.test.ts',
'../web/src/routes/sleepVisualProjection.ts',
'../web/src/routes/sleepVisualProjection.test.ts',
'../web/src/report/sleepDiaryPdf.ts',
'../web/src/report/sleepDiaryPdf.test.ts',
'../web/src/routes/AnalysisPage.test.tsx',

View file

@ -11,6 +11,7 @@ import {
timelineInterval,
timelinePosition,
} from '../routes/sleepTimelineGeometry'
import { sleepDisplayRanges } from '../routes/sleepVisualProjection'
import { heartRateVariations } from './heartRateVariation'
const WIDTH = 1000
@ -96,36 +97,6 @@ function doseLabel(intake: MedicationIntake): string {
return `${intake.medication_name} · ${intake.dose_value} ${intake.dose_unit ?? ''}${quantity}`.trim()
}
interface SleepRange {
start: number
end: number
estimated: boolean
}
function sleepRanges(entry: SleepEntry): SleepRange[] {
// CONTRACT: recorded Sleep intervals always outrank visualization-only
// estimates. The fallback never mutates the entry or claims a medical
// inference; incomplete bounds deliberately produce no range.
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
const bedTime = entry.events.find((event) => event.type === 'bed_time')
const finalGetUp = entry.events.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 }]
: []
}
function markerTitle(event: SleepEvent, french: boolean): string {
const timelineEvent: HeartRateTimelineEvent = {
type: event.type,
@ -180,7 +151,7 @@ export function HeartRateTimelinePanel({
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
}, [timeline])
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
const sleep = sleepEntry ? sleepDisplayRanges(sleepEntry) : []
if (failed) {
return <section className="heart-rate-panel" role="alert">

View file

@ -640,10 +640,66 @@ describe("SleepDiaryWorkspace", () => {
);
expect(intake).toHaveStyle({ left: "18.75%" });
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([
["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"],
["12:00", "75%"], ["18:00", "100%"],
expect(ticks).toHaveLength(25);
expect(
ticks
.map((tick) => [tick.textContent, tick.style.left])
.filter((_, index) => index % 2 === 0),
).toEqual([
["18", "0%"],
["20", `${(2 / 24) * 100}%`],
["22", `${(4 / 24) * 100}%`],
["00", "25%"],
["02", `${(8 / 24) * 100}%`],
["04", `${(10 / 24) * 100}%`],
["06", "50%"],
["08", `${(14 / 24) * 100}%`],
["10", `${(16 / 24) * 100}%`],
["12", "75%"],
["14", `${(20 / 24) * 100}%`],
["16", `${(22 / 24) * 100}%`],
["18", "100%"],
]);
expect(ticks[1]).toHaveClass("sleep-hour-tick-odd");
expect(ticks[2]).toHaveClass("sleep-hour-tick-even");
expect(ticks[6]).toHaveClass("sleep-hour-tick-major");
});
it("shows the shared estimated sleep range only when factual sleep is absent", async () => {
const estimated = {
...dayAEntry(),
entry_id: "sl_estimated",
revision_id: "slr_estimated",
night_start_date: "2026-09-23",
night_end_date: "2026-09-24",
events: [
{
event_id: "bed",
type: "bed_time" as const,
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
{
event_id: "up",
type: "final_get_up" as const,
start_at: "2026-09-24T04:14:00+02:00",
end_at: null,
},
],
intakes: [],
};
api.fetchSleepDiary.mockResolvedValue(snapshotWith([estimated]));
api.fetchSleepMedications.mockResolvedValue([medication]);
render(<SleepDiaryWorkspace period="30d" language="fr" />);
const range = await screen.findByTestId("sleep-agenda-sleep-estimated");
expect(range).toHaveAttribute(
"aria-label",
"Sommeil estimé 22:50 → 04:04",
);
expect(Number.parseFloat(range.style.left)).toBeCloseTo((4.833333333 / 24) * 100, 7);
expect(Number.parseFloat(range.style.width)).toBeCloseTo((5.233333333 / 24) * 100, 7);
expect(screen.queryByTestId("sleep-agenda-event-sleep")).not.toBeInTheDocument();
});
it("isolates an empty editor night while retaining the period agenda", async () => {

View file

@ -25,9 +25,11 @@ import {
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
import { sleepEntryFacts } from "./sleepDiaryFacts";
import {
sleepAgendaTicks,
sleepTimelineInterval,
sleepTimelinePosition,
} from "./sleepTimelineGeometry";
import { sleepDisplayRanges } from "./sleepVisualProjection";
type Language = "fr" | "en";
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
@ -161,6 +163,12 @@ const copy = {
} as const;
const clock = (iso: string) => (iso ? iso.slice(11, 16) : "");
const projectedClock = (timestamp: number) =>
new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
const medicationLabel = (medication: SleepMedication) =>
medication.default_dose_value === null
? medication.name
@ -717,12 +725,22 @@ export function SleepDiaryWorkspace({
<div className="sleep-axis-row" aria-hidden="true">
<span />
<span className="sleep-hour-axis">
{[0, 6, 12, 18, 24].map((index) => (
{sleepAgendaTicks().map((tick) => (
<span
key={index}
style={{ left: `${(index / 24) * 100}%` }}
key={tick.hourOffset}
className={[
"sleep-hour-tick",
tick.hourOffset % 2 === 0
? "sleep-hour-tick-even"
: "sleep-hour-tick-odd",
tick.hourOffset % 6 === 0
? "sleep-hour-tick-major"
: "",
].join(" ")}
data-hour-offset={tick.hourOffset}
style={{ left: `${tick.position * 100}%` }}
>
{String((18 + index) % 24).padStart(2, "0")}:00
{tick.label}
</span>
))}
</span>
@ -730,6 +748,9 @@ export function SleepDiaryWorkspace({
</div>
{agendaEntries.map((entry) => {
const facts = sleepEntryFacts(entry);
const estimatedSleep = sleepDisplayRanges(entry).filter(
(range) => range.estimated,
);
const groupedIntakes = Object.entries(
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
(groups, intake) => {
@ -755,6 +776,31 @@ export function SleepDiaryWorkspace({
</small>
</strong>
<span className="sleep-track">
{estimatedSleep.map((range) => {
const geometry = sleepTimelineInterval(
new Date(range.start).toISOString(),
new Date(range.end).toISOString(),
entry.night_start_date,
entry.created_at,
);
const label = `${
language === "fr" ? "Sommeil estimé" : "Estimated sleep"
} ${projectedClock(range.start)} → ${projectedClock(range.end)}`;
return (
<i
key={`estimated-${range.start}-${range.end}`}
className="sleep-event sleep-sleep sleep-estimated"
data-testid="sleep-agenda-sleep-estimated"
role="img"
aria-label={label}
style={{
left: `${geometry.left * 100}%`,
width: `${geometry.width * 100}%`,
}}
title={label}
/>
);
})}
{entry.events.map((event) => {
const geometry = event.end_at
? sleepTimelineInterval(

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
hourlyTimelineTicks,
sleepAgendaTicks,
sleepTimelineInterval,
sleepTimelinePosition,
timelineInterval,
@ -70,4 +71,27 @@ describe("sleep timeline geometry", () => {
true,
);
});
it("derives exact one-hour and two-hour 18:00-to-18:00 ticks", () => {
const hourly = sleepAgendaTicks();
expect(hourly).toHaveLength(25);
expect(hourly[0]).toEqual({ hourOffset: 0, position: 0, label: "18" });
expect(hourly[6]).toEqual({ hourOffset: 6, position: 0.25, label: "00" });
expect(hourly[24]).toEqual({ hourOffset: 24, position: 1, label: "18" });
expect(sleepAgendaTicks(2).map((tick) => tick.label)).toEqual([
"18",
"20",
"22",
"00",
"02",
"04",
"06",
"08",
"10",
"12",
"14",
"16",
"18",
]);
});
});

View file

@ -5,6 +5,26 @@ export interface TimelineTick {
position: number;
}
export interface SleepAgendaTick {
hourOffset: number;
position: number;
label: string;
}
export function sleepAgendaTicks(stepHours = 1): SleepAgendaTick[] {
if (!Number.isInteger(stepHours) || stepHours < 1 || 24 % stepHours !== 0) {
return [];
}
return Array.from({ length: 24 / stepHours + 1 }, (_, index) => {
const hourOffset = index * stepHours;
return {
hourOffset,
position: hourOffset / 24,
label: String((18 + hourOffset) % 24).padStart(2, "0"),
};
});
}
export function timelinePosition(
timestamp: string | number,
start: string | number,

View file

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { SleepEntry } from "../api/sleepDiary";
import { sleepDisplayRanges } from "./sleepVisualProjection";
const entry = (events: SleepEntry["events"]): SleepEntry => ({
entry_id: "sl_11111111-1111-4111-8111-111111111111",
night_start_date: "2026-09-23",
night_end_date: "2026-09-24",
created_at: "2026-09-23T22:05:00+02:00",
updated_at: "2026-09-24T04:14:00+02:00",
revision_id: "slr_11111111-1111-4111-8111-111111111111",
sleep_quality: null,
wake_quality: null,
day_form: null,
treatment_and_notes: "",
events,
intakes: [],
publication_status: "ready",
});
describe("sleep visual projection", () => {
it("always prefers structured sleep ranges", () => {
const ranges = sleepDisplayRanges(
entry([
{
event_id: "bed",
type: "bed_time",
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
{
event_id: "sleep",
type: "sleep",
start_at: "2026-09-23T22:20:00+02:00",
end_at: "2026-09-24T04:00:00+02:00",
},
{
event_id: "up",
type: "final_get_up",
start_at: "2026-09-24T04:14:00+02:00",
end_at: null,
},
]),
);
expect(ranges).toEqual([
{
start: Date.parse("2026-09-23T22:20:00+02:00"),
end: Date.parse("2026-09-24T04:00:00+02:00"),
estimated: false,
},
]);
});
it("shares the conservative 22:50 to 04:04 fallback", () => {
const ranges = sleepDisplayRanges(
entry([
{
event_id: "bed",
type: "bed_time",
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
{
event_id: "up",
type: "final_get_up",
start_at: "2026-09-24T04:14:00+02:00",
end_at: null,
},
]),
);
expect(ranges).toEqual([
{
start: Date.parse("2026-09-23T22:50:00+02:00"),
end: Date.parse("2026-09-24T04:04:00+02:00"),
estimated: true,
},
]);
});
it("does not invent an estimate with a missing bound", () => {
expect(
sleepDisplayRanges(
entry([
{
event_id: "bed",
type: "bed_time",
start_at: "2026-09-23T22:05:00+02:00",
end_at: null,
},
]),
),
).toEqual([]);
});
});

View file

@ -0,0 +1,34 @@
import type { SleepEntry } from "../api/sleepDiary";
export interface SleepDisplayRange {
start: number;
end: number;
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;
const bedTime = entry.events.find((event) => event.type === "bed_time");
const finalGetUp = entry.events.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 }]
: [];
}

View file

@ -714,6 +714,9 @@ h2 {
transform: translateX(-50%);
font-variant-numeric: tabular-nums;
}
.sleep-hour-tick-odd {
display: none;
}
.sleep-hour-axis > span:first-child {
transform: none;
}
@ -823,6 +826,11 @@ h2 {
transparent 3px 6px
);
}
.sleep-estimated {
opacity: 0.72;
outline: 1px dashed var(--teal);
outline-offset: -1px;
}
.sleep-long_awake {
border: 2px solid var(--peach);
background: transparent;
@ -845,6 +853,16 @@ h2 {
color: var(--crust);
font-weight: 800;
}
@media (min-width: 1600px) {
.sleep-hour-tick-odd {
display: block;
}
}
@media (max-width: 760px) {
.sleep-hour-tick-even:not(.sleep-hour-tick-major) {
display: none;
}
}
.sleep-editor-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));