Compare commits
3 commits
fe79c44e51
...
4cb911d5c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cb911d5c4 | ||
|
|
a60e1aa056 | ||
|
|
696dac82a3 |
14
CHANGELOG.md
|
|
@ -45,6 +45,20 @@ also refined.
|
||||||
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
|
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
|
||||||
real HR night and the latest real night containing medications from code
|
real HR night and the latest real night containing medications from code
|
||||||
commit `2ce458d`.
|
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
|
## 0.1.5 — 2026-09-23
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,16 +92,55 @@ class SyncGenerationServiceTest {
|
||||||
active = true,
|
active = true,
|
||||||
)
|
)
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
) 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 =
|
val preBed =
|
||||||
source.quickSleepMedication(
|
source.quickSleepMedication(
|
||||||
medicationCreated.entryId,
|
medicationCreated.entryId,
|
||||||
"2026-09-23T22:20:00+02:00",
|
"2026-09-23T22:20:00+02:00",
|
||||||
quantity = 2,
|
quantity = 2,
|
||||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
) 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 =
|
val bed =
|
||||||
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
||||||
|
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||||
|
source.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
val firstMeasurement =
|
val firstMeasurement =
|
||||||
ParsedHeartRateMeasurement(
|
ParsedHeartRateMeasurement(
|
||||||
bpm = 61,
|
bpm = 61,
|
||||||
|
|
@ -120,6 +159,9 @@ class SyncGenerationServiceTest {
|
||||||
source.quickSleepWake("2026-09-24T02:17:00+02:00")
|
source.quickSleepWake("2026-09-24T02:17:00+02:00")
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
)
|
)
|
||||||
|
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||||
|
source.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
source.recordLiveHeartRateForActiveSleep(
|
source.recordLiveHeartRateForActiveSleep(
|
||||||
"Synthetic HR",
|
"Synthetic HR",
|
||||||
|
|
@ -131,6 +173,8 @@ class SyncGenerationServiceTest {
|
||||||
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
)
|
)
|
||||||
|
source.close()
|
||||||
|
source = TrainlogRepository(context, sourceName)
|
||||||
assertTrue(source.listSessions().isEmpty())
|
assertTrue(source.listSessions().isEmpty())
|
||||||
|
|
||||||
val night = source.listSleepDiary().single()
|
val night = source.listSleepDiary().single()
|
||||||
|
|
@ -140,7 +184,7 @@ class SyncGenerationServiceTest {
|
||||||
.getJSONObject(0)
|
.getJSONObject(0)
|
||||||
assertEquals(night.entryId, capture.getString("context_id"))
|
assertEquals(night.entryId, capture.getString("context_id"))
|
||||||
assertEquals(2, capture.getJSONArray("samples").length())
|
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 service = SyncGenerationService(source)
|
||||||
val captured = service.capture(root, desktopPeer)
|
val captured = service.capture(root, desktopPeer)
|
||||||
|
|
@ -154,9 +198,12 @@ class SyncGenerationServiceTest {
|
||||||
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
|
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
|
||||||
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
|
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
|
||||||
assertEquals(night.entryId, publishedNight.getString("entry_id"))
|
assertEquals(night.entryId, publishedNight.getString("entry_id"))
|
||||||
|
assertEquals(2, publishedNight.getJSONArray("intakes").length())
|
||||||
assertEquals(
|
assertEquals(
|
||||||
2,
|
listOf(1, 2),
|
||||||
publishedNight.getJSONArray("intakes").getJSONObject(0).getInt("quantity"),
|
(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"))
|
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
|
||||||
|
|
||||||
|
|
@ -193,13 +240,13 @@ class SyncGenerationServiceTest {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
// The desktop fixture owns one reserved calibration session;
|
// The desktop fixture owns one reserved calibration session;
|
||||||
// the empty Android history must not add a workout.
|
// the empty Android history must not add a workout.
|
||||||
"1|1|2|2|2|1",
|
"1|2|2,1|2|2|1",
|
||||||
run(
|
run(
|
||||||
"sqlite3",
|
"sqlite3",
|
||||||
desktopDb.absolutePath,
|
desktopDb.absolutePath,
|
||||||
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
|
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
|
||||||
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
|
"(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_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 heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
||||||
"(SELECT count(*) FROM sessions);",
|
"(SELECT count(*) FROM sessions);",
|
||||||
|
|
@ -207,6 +254,19 @@ class SyncGenerationServiceTest {
|
||||||
.trim(),
|
.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 =
|
val revised =
|
||||||
source.saveSleepDiary(
|
source.saveSleepDiary(
|
||||||
SleepDiaryDraft(
|
SleepDiaryDraft(
|
||||||
|
|
@ -255,6 +315,7 @@ class SyncGenerationServiceTest {
|
||||||
)
|
)
|
||||||
.trim(),
|
.trim(),
|
||||||
)
|
)
|
||||||
|
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||||
assertTrue(source.listSessions().isEmpty())
|
assertTrue(source.listSessions().isEmpty())
|
||||||
} finally {
|
} finally {
|
||||||
source.close()
|
source.close()
|
||||||
|
|
|
||||||
|
|
@ -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
|
facts. Pending entries persist through restart and can synchronize without
|
||||||
inventing bedtime.
|
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
|
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
|
||||||
no capture. After Couché, the first fresh real measurement creates or resumes
|
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
|
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| 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. |
|
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
||||||
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
||||||
| Current operational cursor | 0.1.6 Sleep graph overlays 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 |
|
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_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
|
unit dose and quantity as separate facts. A missing declared sleep interval
|
||||||
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
|
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
|
||||||
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
|
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
|
||||||
data. The overlays remain available without an HR capture and do not fabricate
|
data. The selected-night graph and the period agenda consume the same
|
||||||
BPM.
|
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
|
Language is device-local presentation state. Android keeps it in dedicated
|
||||||
SharedPreferences; desktop keeps it in the XDG configuration file
|
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
|
factual completed/total text. Responsive charts preserve their viewBox aspect
|
||||||
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
|
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
|
||||||
partial measured coverage instead of deriving time from sets or repetitions.
|
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
|
The shared Sleep agenda owns one label/track/facts grid. Its 25 exact hourly
|
||||||
and every event, interval and medication marker use the same coordinates:
|
positions and every event, interval and medication marker use the same
|
||||||
18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00 at 100%.
|
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,
|
The Equipment route projects supplied and custom definitions with exercise,
|
||||||
history, preparation and Program reference counts. Its guarded merge accepts a
|
history, preparation and Program reference counts. Its guarded merge accepts a
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
|
@ -92,8 +92,8 @@ stream
|
||||||
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
|
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 658.0 522.0 Td (-) Tj ET
|
||||||
0 g BT /F1 7 Tf 696.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 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 (nlafaxine \227 37,5 mg) 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 513.0 Td () Tj ET
|
||||||
0 g BT /F1 4 Tf 730.0 504.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
|
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 570.3333333333333 458 m 570.3333333333333 498 l S
|
||||||
0.9 G 591.1666666666667 458 m 591.1666666666667 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.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.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 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 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 8 Tf 559.9 473.0 Td (S) Tj ET
|
||||||
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
|
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 140 KiB |
|
|
@ -65,11 +65,14 @@ synchronization transport.
|
||||||
|
|
||||||
The 0.1.6 Web refinement now also layers factual Sleep intervals, awakenings,
|
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.
|
medication intakes and complete hourly ticks on the selected night's HR chart.
|
||||||
Its explicitly labeled fallback estimate is presentation-only; release work
|
Its explicitly labeled fallback estimate is presentation-only and is shared by
|
||||||
remains the next separate operational step.
|
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
|
```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
|
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||||
|
|
|
||||||
|
|
@ -25,27 +25,39 @@ still conflicts. The equipment client test removes `crypto.randomUUID`, uses
|
||||||
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
|
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
|
||||||
request; native merge tests retain their transaction/rollback coverage.
|
request; native merge tests retain their transaction/rollback coverage.
|
||||||
|
|
||||||
The real Sleep Bluetooth recovery regression starts with no Android session,
|
The real Sleep Bluetooth recovery regression starts with no Android session
|
||||||
records a pre-bed medication with `quantity=2`, Couché, two BPM/RR samples
|
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
|
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,
|
Sleep and V1 heart-rate artifacts, imports them with the desktop consumer,
|
||||||
accepts and replays the ACK without duplication, then admits a second
|
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
|
policy with its cooldown, cross-run reuse of an unacknowledged immutable
|
||||||
generation and recognition of its original-run exact generation/digest ACK,
|
generation and recognition of its original-run exact generation/digest ACK,
|
||||||
ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry,
|
ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry,
|
||||||
service-installer upgrade behavior, and ACK-recovery progress frames over the
|
service-installer upgrade behavior, and ACK-recovery progress frames over the
|
||||||
Bluetooth-selected orchestrator path.
|
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
|
Dashboard sizing and reset behavior, English preference persistence across a
|
||||||
remount, centralized Dashboard English labels, non-stretched chart aspect
|
remount, centralized Dashboard English labels, non-stretched chart aspect
|
||||||
ratios, and distinct missing/partial exercise-timing messages.
|
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
|
the production build; native Meson **111/111**; Android `testDebugUnitTest` and
|
||||||
`assembleDebug`; JSON/import validators; changed-C formatting; and diff checks.
|
`assembleDebug`; JSON/import validators; changed-C formatting; and diff checks.
|
||||||
The restarted private service serves the rebuilt executable byte-for-byte from
|
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
|
Sleep Diary coverage includes schema creation/migration, midnight and DST
|
||||||
offsets, revision conflict, logical deletion/reopen, companion replay and
|
offsets, revision conflict, logical deletion/reopen, companion replay and
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,7 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
};
|
};
|
||||||
const sleep=box('sleep-agenda-event-sleep');
|
const sleep=box('sleep-agenda-event-sleep');
|
||||||
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||||
|
.filter(value => getComputedStyle(value).display !== 'none')
|
||||||
.map(value => {
|
.map(value => {
|
||||||
const tick=value.getBoundingClientRect();
|
const tick=value.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
|
|
@ -460,9 +461,9 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
expected_x, geometry[key], delta=1.0, msg=key
|
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(
|
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"]],
|
[tick["label"] for tick in geometry["ticks"]],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
@ -528,6 +529,14 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
response = json.loads(bidi.recv())
|
response = json.loads(bidi.recv())
|
||||||
self.assertEqual("success", response["type"])
|
self.assertEqual("success", response["type"])
|
||||||
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
|
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(
|
self.assertLessEqual(
|
||||||
driver.execute_script("return document.documentElement.scrollWidth"),
|
driver.execute_script("return document.documentElement.scrollWidth"),
|
||||||
driver.execute_script("return document.documentElement.clientWidth"),
|
driver.execute_script("return document.documentElement.clientWidth"),
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ if web_assets_enabled
|
||||||
'../web/src/routes/SleepDiaryWorkspace.tsx',
|
'../web/src/routes/SleepDiaryWorkspace.tsx',
|
||||||
'../web/src/routes/sleepTimelineGeometry.ts',
|
'../web/src/routes/sleepTimelineGeometry.ts',
|
||||||
'../web/src/routes/sleepTimelineGeometry.test.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.ts',
|
||||||
'../web/src/report/sleepDiaryPdf.test.ts',
|
'../web/src/report/sleepDiaryPdf.test.ts',
|
||||||
'../web/src/routes/AnalysisPage.test.tsx',
|
'../web/src/routes/AnalysisPage.test.tsx',
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
timelineInterval,
|
timelineInterval,
|
||||||
timelinePosition,
|
timelinePosition,
|
||||||
} from '../routes/sleepTimelineGeometry'
|
} from '../routes/sleepTimelineGeometry'
|
||||||
|
import { sleepDisplayRanges } from '../routes/sleepVisualProjection'
|
||||||
import { heartRateVariations } from './heartRateVariation'
|
import { heartRateVariations } from './heartRateVariation'
|
||||||
|
|
||||||
const WIDTH = 1000
|
const WIDTH = 1000
|
||||||
|
|
@ -96,36 +97,6 @@ function doseLabel(intake: MedicationIntake): string {
|
||||||
return `${intake.medication_name} · ${intake.dose_value} ${intake.dose_unit ?? ''}${quantity}`.trim()
|
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 {
|
function markerTitle(event: SleepEvent, french: boolean): string {
|
||||||
const timelineEvent: HeartRateTimelineEvent = {
|
const timelineEvent: HeartRateTimelineEvent = {
|
||||||
type: event.type,
|
type: event.type,
|
||||||
|
|
@ -180,7 +151,7 @@ export function HeartRateTimelinePanel({
|
||||||
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
||||||
}, [timeline])
|
}, [timeline])
|
||||||
|
|
||||||
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
|
const sleep = sleepEntry ? sleepDisplayRanges(sleepEntry) : []
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
return <section className="heart-rate-panel" role="alert">
|
return <section className="heart-rate-panel" role="alert">
|
||||||
|
|
|
||||||
|
|
@ -640,10 +640,66 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
);
|
);
|
||||||
expect(intake).toHaveStyle({ left: "18.75%" });
|
expect(intake).toHaveStyle({ left: "18.75%" });
|
||||||
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
|
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
|
||||||
expect(ticks.map((tick) => [tick.textContent, tick.style.left])).toEqual([
|
expect(ticks).toHaveLength(25);
|
||||||
["18:00", "0%"], ["00:00", "25%"], ["06:00", "50%"],
|
expect(
|
||||||
["12:00", "75%"], ["18:00", "100%"],
|
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 () => {
|
it("isolates an empty editor night while retaining the period agenda", async () => {
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,11 @@ import {
|
||||||
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
|
||||||
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
import { sleepEntryFacts } from "./sleepDiaryFacts";
|
||||||
import {
|
import {
|
||||||
|
sleepAgendaTicks,
|
||||||
sleepTimelineInterval,
|
sleepTimelineInterval,
|
||||||
sleepTimelinePosition,
|
sleepTimelinePosition,
|
||||||
} from "./sleepTimelineGeometry";
|
} from "./sleepTimelineGeometry";
|
||||||
|
import { sleepDisplayRanges } from "./sleepVisualProjection";
|
||||||
|
|
||||||
type Language = "fr" | "en";
|
type Language = "fr" | "en";
|
||||||
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
const q: readonly SleepQuality[] = ["TB", "B", "Moy", "M", "TM"];
|
||||||
|
|
@ -161,6 +163,12 @@ const copy = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const clock = (iso: string) => (iso ? iso.slice(11, 16) : "");
|
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) =>
|
const medicationLabel = (medication: SleepMedication) =>
|
||||||
medication.default_dose_value === null
|
medication.default_dose_value === null
|
||||||
? medication.name
|
? medication.name
|
||||||
|
|
@ -717,12 +725,22 @@ export function SleepDiaryWorkspace({
|
||||||
<div className="sleep-axis-row" aria-hidden="true">
|
<div className="sleep-axis-row" aria-hidden="true">
|
||||||
<span />
|
<span />
|
||||||
<span className="sleep-hour-axis">
|
<span className="sleep-hour-axis">
|
||||||
{[0, 6, 12, 18, 24].map((index) => (
|
{sleepAgendaTicks().map((tick) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={tick.hourOffset}
|
||||||
style={{ left: `${(index / 24) * 100}%` }}
|
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>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -730,6 +748,9 @@ export function SleepDiaryWorkspace({
|
||||||
</div>
|
</div>
|
||||||
{agendaEntries.map((entry) => {
|
{agendaEntries.map((entry) => {
|
||||||
const facts = sleepEntryFacts(entry);
|
const facts = sleepEntryFacts(entry);
|
||||||
|
const estimatedSleep = sleepDisplayRanges(entry).filter(
|
||||||
|
(range) => range.estimated,
|
||||||
|
);
|
||||||
const groupedIntakes = Object.entries(
|
const groupedIntakes = Object.entries(
|
||||||
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
entry.intakes.reduce<Record<string, MedicationIntake[]>>(
|
||||||
(groups, intake) => {
|
(groups, intake) => {
|
||||||
|
|
@ -755,6 +776,31 @@ export function SleepDiaryWorkspace({
|
||||||
</small>
|
</small>
|
||||||
</strong>
|
</strong>
|
||||||
<span className="sleep-track">
|
<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) => {
|
{entry.events.map((event) => {
|
||||||
const geometry = event.end_at
|
const geometry = event.end_at
|
||||||
? sleepTimelineInterval(
|
? sleepTimelineInterval(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hourlyTimelineTicks,
|
hourlyTimelineTicks,
|
||||||
|
sleepAgendaTicks,
|
||||||
sleepTimelineInterval,
|
sleepTimelineInterval,
|
||||||
sleepTimelinePosition,
|
sleepTimelinePosition,
|
||||||
timelineInterval,
|
timelineInterval,
|
||||||
|
|
@ -70,4 +71,27 @@ describe("sleep timeline geometry", () => {
|
||||||
true,
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,26 @@ export interface TimelineTick {
|
||||||
position: number;
|
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(
|
export function timelinePosition(
|
||||||
timestamp: string | number,
|
timestamp: string | number,
|
||||||
start: string | number,
|
start: string | number,
|
||||||
|
|
|
||||||
94
web/src/routes/sleepVisualProjection.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
web/src/routes/sleepVisualProjection.ts
Normal 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 }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
@ -714,6 +714,9 @@ h2 {
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.sleep-hour-tick-odd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.sleep-hour-axis > span:first-child {
|
.sleep-hour-axis > span:first-child {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
@ -823,6 +826,11 @@ h2 {
|
||||||
transparent 3px 6px
|
transparent 3px 6px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
.sleep-estimated {
|
||||||
|
opacity: 0.72;
|
||||||
|
outline: 1px dashed var(--teal);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
.sleep-long_awake {
|
.sleep-long_awake {
|
||||||
border: 2px solid var(--peach);
|
border: 2px solid var(--peach);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -845,6 +853,16 @@ h2 {
|
||||||
color: var(--crust);
|
color: var(--crust);
|
||||||
font-weight: 800;
|
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 {
|
.sleep-editor-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
|
||||||