fix(sleep): record wake taps as timed intervals
This commit is contained in:
parent
de4b77d59b
commit
23717ba6da
13 changed files with 194 additions and 38 deletions
|
|
@ -67,6 +67,11 @@ also refined.
|
||||||
sleep intervals or, only when none exist, uses the displayed Couché +45 min
|
sleep intervals or, only when none exist, uses the displayed Couché +45 min
|
||||||
to Levé -10 min estimate minus structured awakenings; the estimate is never
|
to Levé -10 min estimate minus structured awakenings; the estimate is never
|
||||||
written to SQLite.
|
written to SQLite.
|
||||||
|
- Changed future Android one-tap Réveil capture from punctual `night_get_up`
|
||||||
|
facts to structured `long_awake` windows under the user-approved 30-minute
|
||||||
|
default. A repeated tap inside the window extends its end to 30 minutes after
|
||||||
|
the latest tap, while Levé clamps it to the factual final-get-up timestamp.
|
||||||
|
The policy is revisioned, undoable, and independent of BPM/RR inference.
|
||||||
|
|
||||||
## 0.1.5 — 2026-09-23
|
## 0.1.5 — 2026-09-23
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ import java.security.MessageDigest
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatterBuilder
|
||||||
|
import java.time.temporal.ChronoField
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -88,6 +90,11 @@ private val PROFILE_REVISION_NAMESPACE = UUID.fromString("4f4c8ea6-18f6-5e48-9d6
|
||||||
private val PROFILE_REVISION_PATTERN = Regex("^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
private val PROFILE_REVISION_PATTERN = Regex("^pr2_[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||||
private val PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$")
|
private val PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$")
|
||||||
private const val PROFILE_HISTORY_MAX = 32
|
private const val PROFILE_HISTORY_MAX = 32
|
||||||
|
private val SLEEP_QUICK_TIMESTAMP = DateTimeFormatterBuilder()
|
||||||
|
.appendPattern("uuuu-MM-dd'T'HH:mm:ss")
|
||||||
|
.appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
|
||||||
|
.appendOffsetId()
|
||||||
|
.toFormatter()
|
||||||
|
|
||||||
/** Fixed-width UUIDv5 identity over the direct causal parent and resulting profile. */
|
/** Fixed-width UUIDv5 identity over the direct causal parent and resulting profile. */
|
||||||
internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String {
|
internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String {
|
||||||
|
|
@ -858,16 +865,38 @@ class TrainlogRepository(
|
||||||
|
|
||||||
fun quickSleepWake(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult =
|
fun quickSleepWake(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult =
|
||||||
mutateActiveSleep(at) { current ->
|
mutateActiveSleep(at) { current ->
|
||||||
current.quickDraft(
|
val wake = OffsetDateTime.parse(at)
|
||||||
at,
|
val inferredEnd = wake.plusMinutes(30).format(SLEEP_QUICK_TIMESTAMP)
|
||||||
events = current.events + SleepDiaryEvent(
|
val openIndex = current.events.indexOfLast { event ->
|
||||||
|
event.type == SleepEventType.LONG_AWAKE &&
|
||||||
|
event.endAt != null &&
|
||||||
|
!wake.isBefore(OffsetDateTime.parse(event.startAt)) &&
|
||||||
|
!wake.isAfter(OffsetDateTime.parse(event.endAt))
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* WHY: one-tap capture cannot require a second interaction from a
|
||||||
|
* partly awake user, but a point event cannot drive an awakening
|
||||||
|
* interval in agenda/PDF projections.
|
||||||
|
* CONTRACT: Réveil records a 30-minute LONG_AWAKE interval. A new
|
||||||
|
* tap inside that window resets its inferred end to 30 minutes
|
||||||
|
* after the latest tap; Levé may subsequently clamp it.
|
||||||
|
* INVARIANT: this is explicit product policy, never an inference
|
||||||
|
* from BPM/RR, and every persisted LONG_AWAKE has start < end.
|
||||||
|
*/
|
||||||
|
val events = if (openIndex >= 0) {
|
||||||
|
current.events.mapIndexed { index, event ->
|
||||||
|
if (index == openIndex) event.copy(endAt = inferredEnd) else event
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.events + SleepDiaryEvent(
|
||||||
sleepId("sle"),
|
sleepId("sle"),
|
||||||
SleepEventType.NIGHT_GET_UP,
|
SleepEventType.LONG_AWAKE,
|
||||||
at,
|
at,
|
||||||
null,
|
inferredEnd,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
current.quickDraft(at, events = events)
|
||||||
|
}
|
||||||
|
|
||||||
fun quickSleepMedication(
|
fun quickSleepMedication(
|
||||||
medicationId: String,
|
medicationId: String,
|
||||||
|
|
@ -933,9 +962,24 @@ class TrainlogRepository(
|
||||||
val activeBefore = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
|
val activeBefore = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
|
||||||
val result =
|
val result =
|
||||||
mutateActiveSleep(at) { current ->
|
mutateActiveSleep(at) { current ->
|
||||||
|
val finalGetUp = OffsetDateTime.parse(at)
|
||||||
|
val boundedEvents = current.events.map { event ->
|
||||||
|
if (
|
||||||
|
event.type == SleepEventType.LONG_AWAKE &&
|
||||||
|
event.endAt != null &&
|
||||||
|
OffsetDateTime.parse(event.startAt).isBefore(finalGetUp) &&
|
||||||
|
OffsetDateTime.parse(event.endAt).isAfter(finalGetUp)
|
||||||
|
) {
|
||||||
|
// CONTRACT: final get-up is a factual upper bound; an
|
||||||
|
// inferred 30-minute awakening never extends past it.
|
||||||
|
event.copy(endAt = at)
|
||||||
|
} else {
|
||||||
|
event
|
||||||
|
}
|
||||||
|
}
|
||||||
current.quickDraft(
|
current.quickDraft(
|
||||||
at,
|
at,
|
||||||
events = current.events + SleepDiaryEvent(
|
events = boundedEvents + SleepDiaryEvent(
|
||||||
sleepId("sle"),
|
sleepId("sle"),
|
||||||
SleepEventType.FINAL_GET_UP,
|
SleepEventType.FINAL_GET_UP,
|
||||||
at,
|
at,
|
||||||
|
|
|
||||||
|
|
@ -544,7 +544,7 @@
|
||||||
<string name="sleep_quick_wake">👁 WAKE</string>
|
<string name="sleep_quick_wake">👁 WAKE</string>
|
||||||
<string name="sleep_quick_get_up">☀ GET UP</string>
|
<string name="sleep_quick_get_up">☀ GET UP</string>
|
||||||
<string name="sleep_bed_recorded">Bed time recorded.</string>
|
<string name="sleep_bed_recorded">Bed time recorded.</string>
|
||||||
<string name="sleep_wake_recorded">Wake recorded.</string>
|
<string name="sleep_wake_recorded">30-minute awakening recorded.</string>
|
||||||
<string name="sleep_get_up_recorded">Get-up recorded.</string>
|
<string name="sleep_get_up_recorded">Get-up recorded.</string>
|
||||||
<string name="sleep_medication_recorded">%1$s recorded.</string>
|
<string name="sleep_medication_recorded">%1$s recorded.</string>
|
||||||
<string name="sleep_undo">UNDO</string>
|
<string name="sleep_undo">UNDO</string>
|
||||||
|
|
|
||||||
|
|
@ -554,7 +554,7 @@
|
||||||
<string name="sleep_quick_wake">👁 RÉVEIL</string>
|
<string name="sleep_quick_wake">👁 RÉVEIL</string>
|
||||||
<string name="sleep_quick_get_up">☀ LEVÉ</string>
|
<string name="sleep_quick_get_up">☀ LEVÉ</string>
|
||||||
<string name="sleep_bed_recorded">Couché enregistré.</string>
|
<string name="sleep_bed_recorded">Couché enregistré.</string>
|
||||||
<string name="sleep_wake_recorded">Réveil enregistré.</string>
|
<string name="sleep_wake_recorded">Réveil de 30 minutes enregistré.</string>
|
||||||
<string name="sleep_get_up_recorded">Levé enregistré.</string>
|
<string name="sleep_get_up_recorded">Levé enregistré.</string>
|
||||||
<string name="sleep_medication_recorded">%1$s enregistré.</string>
|
<string name="sleep_medication_recorded">%1$s enregistré.</string>
|
||||||
<string name="sleep_undo">ANNULER</string>
|
<string name="sleep_undo">ANNULER</string>
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,18 @@ class SleepQuickCaptureTest {
|
||||||
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
|
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
2,
|
2,
|
||||||
beforeGetUp.events.count { it.type == SleepEventType.NIGHT_GET_UP },
|
beforeGetUp.events.count { it.type == SleepEventType.LONG_AWAKE },
|
||||||
)
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"2026-09-24T02:17:00+02:00" to "2026-09-24T02:47:00+02:00",
|
||||||
|
"2026-09-24T04:42:00+02:00" to "2026-09-24T05:12:00+02:00",
|
||||||
|
),
|
||||||
|
beforeGetUp.events
|
||||||
|
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||||
|
.map { it.startAt to it.endAt },
|
||||||
|
)
|
||||||
|
assertTrue(beforeGetUp.events.none { it.type == SleepEventType.NIGHT_GET_UP })
|
||||||
assertEquals(2, beforeGetUp.intakes.size)
|
assertEquals(2, beforeGetUp.intakes.size)
|
||||||
assertEquals(2, beforeGetUp.intakes.first().quantity)
|
assertEquals(2, beforeGetUp.intakes.first().quantity)
|
||||||
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
|
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
|
||||||
|
|
@ -217,7 +227,7 @@ class SleepQuickCaptureTest {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("2026-09-24T02:00:00+02:00"),
|
listOf("2026-09-24T02:00:00+02:00"),
|
||||||
night.events
|
night.events
|
||||||
.filter { it.type == SleepEventType.NIGHT_GET_UP }
|
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||||
.map { it.startAt },
|
.map { it.startAt },
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -226,6 +236,37 @@ class SleepQuickCaptureTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repeatedWakeExtendsOneWindowAndFinalGetUpClampsIt() {
|
||||||
|
val name = "sleep-wake-window-" + UUID.randomUUID()
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
repository.quickSleepBedTime("2026-09-23T22:05:00+02:00")
|
||||||
|
repository.quickSleepWake("2026-09-24T04:11:00+02:00")
|
||||||
|
repository.quickSleepWake("2026-09-24T04:14:00+02:00")
|
||||||
|
|
||||||
|
val extended = repository.listSleepDiary().single()
|
||||||
|
assertEquals(
|
||||||
|
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:44:00+02:00"),
|
||||||
|
extended.events
|
||||||
|
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||||
|
.map { it.startAt to it.endAt },
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.quickSleepFinalGetUp("2026-09-24T04:14:30+02:00")
|
||||||
|
val completed = repository.listSleepDiary().single()
|
||||||
|
assertEquals(
|
||||||
|
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:14:30+02:00"),
|
||||||
|
completed.events
|
||||||
|
.filter { it.type == SleepEventType.LONG_AWAKE }
|
||||||
|
.map { it.startAt to it.endAt },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
|
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
|
||||||
val name = "sleep-no-sensor-" + UUID.randomUUID()
|
val name = "sleep-no-sensor-" + UUID.randomUUID()
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,13 @@ stale intake-free ancestor and a later current revision. This is the executable
|
||||||
contract for lifecycle and synchronization retention; it does not infer facts
|
contract for lifecycle and synchronization retention; it does not infer facts
|
||||||
that are absent from a device database.
|
that are absent from a device database.
|
||||||
|
|
||||||
|
The one-tap Réveil action records `long_awake` rather than a point
|
||||||
|
`night_get_up`: its factual start is the tap timestamp and its policy end is 30
|
||||||
|
minutes later. Another tap inside that window extends the same interval to 30
|
||||||
|
minutes after the latest tap; Levé clamps an overlapping interval to the exact
|
||||||
|
final-get-up timestamp. This explicit default requires no Rendormi interaction,
|
||||||
|
remains revisioned/undoable, and is not derived from heart-rate measurements.
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ parallel implementation of its rules.
|
||||||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
||||||
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
||||||
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
|
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
|
||||||
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: existing one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
|
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. Réveil now creates a structured 30-minute `long_awake` window, repeated taps extend it, and Levé clamps it. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
|
||||||
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
|
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
|
||||||
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
|
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
|
||||||
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
|
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
|
||||||
|
|
@ -171,6 +171,7 @@ parallel implementation of its rules.
|
||||||
| 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, 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, shared agenda estimate and denser responsive agenda axis validated and privately served from Web code commit `a60e1aa`; immutable stable v0.1.5 remains unchanged. |
|
||||||
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda, selected HR detail and selected-only PDF; estimated sleep duration is consistent and remains non-persistent; Web 208/208 plus real read-only Firefox/PDF smoke passed. |
|
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda, selected HR detail and selected-only PDF; estimated sleep duration is consistent and remains non-persistent; Web 208/208 plus real read-only Firefox/PDF smoke passed. |
|
||||||
|
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
|
||||||
| 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` |
|
||||||
|
|
|
||||||
|
|
@ -320,14 +320,15 @@ name/dose/unit snapshot. Full editing remains available later.
|
||||||
|
|
||||||
### Réveil
|
### Réveil
|
||||||
|
|
||||||
There is no `Rendormi` button.
|
There is no `Rendormi` button. One tap records a structured `long_awake`
|
||||||
|
interval beginning at the factual tap time and ending 30 minutes later under
|
||||||
|
the explicit capture policy. Nothing else is required before the user tries to
|
||||||
|
return to sleep. A second tap within that window resets its end to 30 minutes
|
||||||
|
after the latest tap. Levé truncates an overlapping interval to the factual
|
||||||
|
final-get-up timestamp.
|
||||||
|
|
||||||
One tap records one punctual night wake event. Nothing else is required before
|
The 30-minute end is user-approved product policy, not an inference from BPM or
|
||||||
the user tries to return to sleep.
|
RR data. Later correction remains available when the actual duration is known.
|
||||||
|
|
||||||
Trainlog does not infer a certain return-to-sleep time from BPM or RR data.
|
|
||||||
An approximate interval may be completed later only if the user chooses to
|
|
||||||
provide it.
|
|
||||||
|
|
||||||
### Levé
|
### Levé
|
||||||
|
|
||||||
|
|
@ -347,11 +348,11 @@ Undo is scoped to that exact action and must not roll back unrelated newer
|
||||||
state.
|
state.
|
||||||
|
|
||||||
`Corriger la nuit` provides later editing of timestamps, medication
|
`Corriger la nuit` provides later editing of timestamps, medication
|
||||||
intakes, accidental wake points, forgotten events, and existing subjective
|
intakes, awakening intervals, forgotten events, and existing subjective
|
||||||
Sleep Diary fields.
|
Sleep Diary fields.
|
||||||
|
|
||||||
The night-time rule is: one tap, one timestamp, then no further required
|
The night-time rule is: one tap, one 30-minute awakening window, then no further
|
||||||
interaction.
|
required interaction.
|
||||||
|
|
||||||
## Sleep + cardio factual timeline
|
## Sleep + cardio factual timeline
|
||||||
|
|
||||||
|
|
@ -361,7 +362,7 @@ After synchronization, Analyse may superpose factual events and measurements:
|
||||||
|
|
||||||
- Couché;
|
- Couché;
|
||||||
- medication intakes;
|
- medication intakes;
|
||||||
- wake points;
|
- structured 30-minute awakening windows;
|
||||||
- Levé;
|
- Levé;
|
||||||
- BPM;
|
- BPM;
|
||||||
- RR when supplied;
|
- RR when supplied;
|
||||||
|
|
|
||||||
|
|
@ -47,5 +47,48 @@ The rebuilt user Web service was exercised through real headless Firefox at
|
||||||
- Raster inspection confirms the long-awake band remains visibly distinct
|
- Raster inspection confirms the long-awake band remains visibly distinct
|
||||||
inside the sleep timeline and point/medication markers retain their positions.
|
inside the sleep timeline and point/medication markers retain their positions.
|
||||||
|
|
||||||
The change affects no database schema, synchronization artifact, frozen format,
|
The original Web change affected no database schema, synchronization artifact,
|
||||||
or Android behavior.
|
frozen format, or Android behavior; the follow-up below deliberately corrects
|
||||||
|
the Android capture semantics without changing those compatibility boundaries.
|
||||||
|
|
||||||
|
## Android capture correction and real-night repair
|
||||||
|
|
||||||
|
Follow-up visual review established that Android one-tap Réveil had produced
|
||||||
|
three punctual `night_get_up` facts for 2026-09-23 → 2026-09-24, so there was
|
||||||
|
no structured interval for the shared projection to color. The user selected
|
||||||
|
an explicit 30-minute no-further-action policy. Future taps now create a
|
||||||
|
`long_awake` interval from the factual tap through +30 minutes; another tap
|
||||||
|
inside the window extends that same interval, while Levé clamps it to the
|
||||||
|
factual final-get-up timestamp. This is product policy, not BPM/RR inference.
|
||||||
|
|
||||||
|
Before repair, separate desktop and Android backups were retained under
|
||||||
|
`~/.local/share/trainlog/backups/sleep-wake-window-20260924T0733/`:
|
||||||
|
|
||||||
|
- desktop SHA-256: `ac2246dc699a5e11e9d8b52c4cad0f096dfc08191f54b6a86c44ce72f623c39c`;
|
||||||
|
- Android SHA-256: `a1a9017875757f27264556ea62a9d27a98f2efad17ca0234fb10f4095170a98a`.
|
||||||
|
|
||||||
|
The real night was advanced causally, retaining its stable `entry_id`, to:
|
||||||
|
|
||||||
|
- `01:35:43.554104 → 02:05:43.554104`;
|
||||||
|
- `04:11:59.245980 → 04:14:28.358915` (bounded by Levé).
|
||||||
|
|
||||||
|
Android and desktop current revision is
|
||||||
|
`slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa`. Its repaired linear ancestry
|
||||||
|
retains desktop revision `slr_2bbf573d-4262-49b0-896e-1bcabf3b002a`, including
|
||||||
|
the existing `TB` sleep and wake qualities.
|
||||||
|
Android database round-trip hashes matched, integrity/foreign-key checks passed,
|
||||||
|
and the non-destructive app update preserved first-install identity while
|
||||||
|
advancing the private build to versionCode 49.
|
||||||
|
|
||||||
|
The first post-repair full-generation attempts correctly rejected an already
|
||||||
|
published Android archive whose embedded ancestry still named the pre-desktop
|
||||||
|
mobile parent. Both live databases are repaired and aligned, but that stale
|
||||||
|
transport archive remains rejected until the normal archive-capacity recovery
|
||||||
|
retires it; no successful synchronization receipt is claimed by this evidence.
|
||||||
|
|
||||||
|
Post-correction validation passed 312 Android JVM tests (five optional skips),
|
||||||
|
`assembleDebug`, 208 Web tests, typecheck, and build. Real Firefox then observed
|
||||||
|
two orange agenda bands, two orange HR bands, `Réveils 2 · 32 min 29 s`, and two
|
||||||
|
orange PDF intervals. The selected-only PDF reports `Sommeil déclaré : 4 h 44
|
||||||
|
min 16 s` after subtracting the repaired awakening intervals from the visual
|
||||||
|
estimate.
|
||||||
|
|
|
||||||
|
|
@ -1823,21 +1823,29 @@ also succeeds. The five skips remain optional historical/external fixture gates.
|
||||||
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
|
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
|
||||||
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
|
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
|
||||||
API creates/advances normal immutable Sleep Diary revisions for Couché,
|
API creates/advances normal immutable Sleep Diary revisions for Couché,
|
||||||
medication intake, point Réveil and Levé actions. Undo is accepted only when
|
medication intake, 30-minute structured Réveil and Levé actions. Undo is accepted only when
|
||||||
the action's exact applied revision is still current; older receipts conflict,
|
the action's exact applied revision is still current; older receipts conflict,
|
||||||
and successful undo appends a compensating revision instead of rewriting the
|
and successful undo appends a compensating revision instead of rewriting the
|
||||||
history.
|
history.
|
||||||
|
|
||||||
Targeted coverage executes Couché, a configured medication with dose snapshot,
|
Targeted coverage executes Couché, a configured medication with dose snapshot,
|
||||||
two independent wake points, live sleep-owned BPM/RR capture, Levé and final
|
two independent awakening intervals, live sleep-owned BPM/RR capture, Levé and final
|
||||||
heart-rate export against the same stable sleep `entry_id`. A separate test
|
heart-rate export against the same stable sleep `entry_id`. A separate test
|
||||||
proves stale undo rejection and exact restoration of the latest action.
|
proves stale undo rejection and exact restoration of the latest action. A
|
||||||
|
dedicated regression proves that a second tap inside 30 minutes extends one
|
||||||
|
interval and that final get-up clamps it without leaving overlapping windows.
|
||||||
|
|
||||||
The complete Android JVM inventory before the final localization-only cleanup
|
The complete Android JVM inventory before the final localization-only cleanup
|
||||||
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
|
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
|
||||||
`assembleDebug` succeeded. After the localization cleanup the targeted
|
`assembleDebug` succeeded. After the localization cleanup the targeted
|
||||||
SleepQuickCapture test and `assembleDebug` passed again.
|
SleepQuickCapture test and `assembleDebug` passed again.
|
||||||
|
|
||||||
|
The 30-minute awakening-policy follow-up passes the current complete inventory:
|
||||||
|
**312 tests: 307 passed, five skipped, zero failures/errors**, plus
|
||||||
|
`assembleDebug`. Real-data Firefox coverage additionally asserts two orange
|
||||||
|
agenda/HR bands and two orange vector-PDF intervals for the repaired
|
||||||
|
2026-09-23 night.
|
||||||
|
|
||||||
The private Android installation was advanced non-destructively to Trainlog
|
The private Android installation was advanced non-destructively to Trainlog
|
||||||
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
|
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
|
||||||
data clear. The device locked before the final visual Sleep-screen inspection,
|
data clear. The device locked before the final visual Sleep-screen inspection,
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ describe("sleep diary PDF", () => {
|
||||||
const source = await buildSleepDiaryPdf(snapshot(1), "fr").text();
|
const source = await buildSleepDiaryPdf(snapshot(1), "fr").text();
|
||||||
const firstSleep = source.indexOf("0.75 g 216.17 509.00 83.33 18.00 re f");
|
const firstSleep = source.indexOf("0.75 g 216.17 509.00 83.33 18.00 re f");
|
||||||
const longAwakening = source.indexOf(
|
const longAwakening = source.indexOf(
|
||||||
"0.5 g 299.50 509.00 15.63 18.00 re f",
|
"0.98 0.70 0.53 rg 299.50 509.00 15.63 18.00 re f",
|
||||||
);
|
);
|
||||||
const secondSleep = source.indexOf("0.75 g 315.13 509.00 67.71 18.00 re f");
|
const secondSleep = source.indexOf("0.75 g 315.13 509.00 67.71 18.00 re f");
|
||||||
expect(firstSleep).toBeGreaterThan(-1);
|
expect(firstSleep).toBeGreaterThan(-1);
|
||||||
|
|
|
||||||
|
|
@ -160,12 +160,18 @@ function row(
|
||||||
);
|
);
|
||||||
const shade = range.kind === "sleep"
|
const shade = range.kind === "sleep"
|
||||||
? range.estimated ? 0.88 : 0.75
|
? range.estimated ? 0.88 : 0.75
|
||||||
: range.kind === "awake"
|
|
||||||
? 0.5
|
|
||||||
: event?.type === "half_sleep" ? 0.87 : 0.93;
|
: event?.type === "half_sleep" ? 0.87 : 0.93;
|
||||||
|
if (range.kind === "awake") {
|
||||||
|
// CONTRACT: structured awakening intervals use the same orange semantic
|
||||||
|
// role in Web, HR, preview, and PDF; point markers remain separate.
|
||||||
|
stream.push(
|
||||||
|
`0.98 0.70 0.53 rg ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0.78 0.42 0.22 RG ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S 0 G 0 g\n`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
stream.push(
|
stream.push(
|
||||||
`${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`,
|
`${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (eventWidth > 24) {
|
if (eventWidth > 24) {
|
||||||
const label = event
|
const label = event
|
||||||
? eventLabels[language][event.type]
|
? eventLabels[language][event.type]
|
||||||
|
|
|
||||||
|
|
@ -3133,8 +3133,8 @@ h2 {
|
||||||
stroke-dasharray: 5 3;
|
stroke-dasharray: 5 3;
|
||||||
}
|
}
|
||||||
.heart-rate-awake-band {
|
.heart-rate-awake-band {
|
||||||
fill: rgb(249 226 175 / 14%);
|
fill: rgb(250 179 135 / 18%);
|
||||||
stroke: rgb(249 226 175 / 55%);
|
stroke: rgb(250 179 135 / 72%);
|
||||||
stroke-width: 0.7;
|
stroke-width: 0.7;
|
||||||
vector-effect: non-scaling-stroke;
|
vector-effect: non-scaling-stroke;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue