Compare commits
No commits in common. "4cb911d5c4faea5abfa7178648635c92b536057e" and "fe79c44e511a8e142fffe45c48c4525104a622e7" have entirely different histories.
4cb911d5c4
...
fe79c44e51
14
CHANGELOG.md
|
|
@ -45,20 +45,6 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -92,55 +92,16 @@ 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,
|
||||
|
|
@ -159,9 +120,6 @@ 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",
|
||||
|
|
@ -173,8 +131,6 @@ 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()
|
||||
|
|
@ -184,7 +140,7 @@ class SyncGenerationServiceTest {
|
|||
.getJSONObject(0)
|
||||
assertEquals(night.entryId, capture.getString("context_id"))
|
||||
assertEquals(2, capture.getJSONArray("samples").length())
|
||||
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
|
||||
assertEquals(2, night.intakes.single().quantity)
|
||||
|
||||
val service = SyncGenerationService(source)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
|
|
@ -198,12 +154,9 @@ 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(
|
||||
listOf(1, 2),
|
||||
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
|
||||
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
|
||||
}.sorted(),
|
||||
2,
|
||||
publishedNight.getJSONArray("intakes").getJSONObject(0).getInt("quantity"),
|
||||
)
|
||||
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
|
||||
|
||||
|
|
@ -240,13 +193,13 @@ class SyncGenerationServiceTest {
|
|||
assertEquals(
|
||||
// The desktop fixture owns one reserved calibration session;
|
||||
// the empty Android history must not add a workout.
|
||||
"1|2|2,1|2|2|1",
|
||||
"1|1|2|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 group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
|
||||
"(SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
|
||||
"(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);",
|
||||
|
|
@ -254,19 +207,6 @@ 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(
|
||||
|
|
@ -315,7 +255,6 @@ class SyncGenerationServiceTest {
|
|||
)
|
||||
.trim(),
|
||||
)
|
||||
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||
assertTrue(source.listSessions().isEmpty())
|
||||
} finally {
|
||||
source.close()
|
||||
|
|
|
|||
|
|
@ -34,14 +34,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -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, shared agenda estimate and denser responsive agenda axis validated and privately served from Web code commit `a60e1aa`; immutable stable v0.1.5 remains unchanged. |
|
||||
| Current operational cursor | 0.1.6 Sleep graph overlays 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. |
|
||||
| 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,11 +195,8 @@ 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. 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
|
||||
|
|
@ -344,12 +341,9 @@ 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 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 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 Equipment route projects supplied and custom definitions with exercise,
|
||||
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: 138 KiB After Width: | Height: | Size: 137 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 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 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 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 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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
|
@ -65,14 +65,11 @@ 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
|
||||
pre-bed medication snapshots against restart, replay and stale ancestry.
|
||||
Release work remains the next separate operational step.
|
||||
Its explicitly labeled fallback estimate is presentation-only; 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 graph overlays and post-review Web corrections validated and privately deployed; stable release work remains separate.
|
||||
```
|
||||
|
||||
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||
|
|
|
|||
|
|
@ -25,39 +25,27 @@ 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
|
||||
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
|
||||
The real Sleep Bluetooth recovery regression starts with no Android session,
|
||||
records a pre-bed medication with `quantity=2`, 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. 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
|
||||
Sleep-only revision. 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 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
|
||||
UI regressions assert the Sleep labels at exactly 0/25/50/75/100%, 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 **32 files / 205 tests**, TypeScript typecheck and
|
||||
The closeout run passes Web **31 files / 196 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`. 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.
|
||||
the development checkout and reports healthy Trainlog `0.1.6`.
|
||||
|
||||
Sleep Diary coverage includes schema creation/migration, midnight and DST
|
||||
offsets, revision conflict, logical deletion/reopen, companion replay and
|
||||
|
|
|
|||
|
|
@ -426,7 +426,6 @@ 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 {
|
||||
|
|
@ -461,9 +460,9 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
|||
self.assertAlmostEqual(
|
||||
expected_x, geometry[key], delta=1.0, msg=key
|
||||
)
|
||||
agenda_tick_hours = tuple(range(0, 25, 2))
|
||||
agenda_tick_hours = (0, 6, 12, 18, 24)
|
||||
self.assertEqual(
|
||||
[f"{(18 + hour) % 24:02d}" for hour in agenda_tick_hours],
|
||||
["18:00", "00:00", "06:00", "12:00", "18:00"],
|
||||
[tick["label"] for tick in geometry["ticks"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
|
|
@ -529,14 +528,6 @@ 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"),
|
||||
|
|
|
|||
|
|
@ -88,8 +88,6 @@ 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',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
timelineInterval,
|
||||
timelinePosition,
|
||||
} from '../routes/sleepTimelineGeometry'
|
||||
import { sleepDisplayRanges } from '../routes/sleepVisualProjection'
|
||||
import { heartRateVariations } from './heartRateVariation'
|
||||
|
||||
const WIDTH = 1000
|
||||
|
|
@ -97,6 +96,36 @@ 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,
|
||||
|
|
@ -151,7 +180,7 @@ export function HeartRateTimelinePanel({
|
|||
return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) }
|
||||
}, [timeline])
|
||||
|
||||
const sleep = sleepEntry ? sleepDisplayRanges(sleepEntry) : []
|
||||
const sleep = sleepEntry ? sleepRanges(sleepEntry) : []
|
||||
|
||||
if (failed) {
|
||||
return <section className="heart-rate-panel" role="alert">
|
||||
|
|
|
|||
|
|
@ -640,66 +640,10 @@ describe("SleepDiaryWorkspace", () => {
|
|||
);
|
||||
expect(intake).toHaveStyle({ left: "18.75%" });
|
||||
const ticks = Array.from(document.querySelectorAll<HTMLElement>(".sleep-hour-axis > span"));
|
||||
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.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[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 () => {
|
||||
|
|
|
|||
|
|
@ -25,11 +25,9 @@ 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"];
|
||||
|
|
@ -163,12 +161,6 @@ 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
|
||||
|
|
@ -725,22 +717,12 @@ export function SleepDiaryWorkspace({
|
|||
<div className="sleep-axis-row" aria-hidden="true">
|
||||
<span />
|
||||
<span className="sleep-hour-axis">
|
||||
{sleepAgendaTicks().map((tick) => (
|
||||
{[0, 6, 12, 18, 24].map((index) => (
|
||||
<span
|
||||
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}%` }}
|
||||
key={index}
|
||||
style={{ left: `${(index / 24) * 100}%` }}
|
||||
>
|
||||
{tick.label}
|
||||
{String((18 + index) % 24).padStart(2, "0")}:00
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
|
@ -748,9 +730,6 @@ 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) => {
|
||||
|
|
@ -776,31 +755,6 @@ 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(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hourlyTimelineTicks,
|
||||
sleepAgendaTicks,
|
||||
sleepTimelineInterval,
|
||||
sleepTimelinePosition,
|
||||
timelineInterval,
|
||||
|
|
@ -71,27 +70,4 @@ 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,26 +5,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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,9 +714,6 @@ h2 {
|
|||
transform: translateX(-50%);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sleep-hour-tick-odd {
|
||||
display: none;
|
||||
}
|
||||
.sleep-hour-axis > span:first-child {
|
||||
transform: none;
|
||||
}
|
||||
|
|
@ -826,11 +823,6 @@ 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;
|
||||
|
|
@ -853,16 +845,6 @@ 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));
|
||||
|
|
|
|||