fix(sleep): record wake taps as timed intervals

This commit is contained in:
fy59 2026-09-24 07:54:29 +02:00
parent de4b77d59b
commit 23717ba6da
13 changed files with 194 additions and 38 deletions

View file

@ -67,6 +67,11 @@ also refined.
sleep intervals or, only when none exist, uses the displayed Couché +45 min
to Levé -10 min estimate minus structured awakenings; the estimate is never
written to SQLite.
- 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

View file

@ -80,6 +80,8 @@ import java.security.MessageDigest
import java.time.Duration
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatterBuilder
import java.time.temporal.ChronoField
import java.time.temporal.WeekFields
import java.util.Locale
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 PROTOTYPE_PROFILE_REVISION_PATTERN = Regex("^pr1\\|(sets|continuous)\\|(reps|duration)\\|([0-9]+)$")
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. */
internal fun exerciseProfileRevision(parent: String, recording: String, tracking: String, fields: Int): String {
@ -858,15 +865,37 @@ class TrainlogRepository(
fun quickSleepWake(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult =
mutateActiveSleep(at) { current ->
current.quickDraft(
at,
events = current.events + SleepDiaryEvent(
val wake = OffsetDateTime.parse(at)
val inferredEnd = wake.plusMinutes(30).format(SLEEP_QUICK_TIMESTAMP)
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"),
SleepEventType.NIGHT_GET_UP,
SleepEventType.LONG_AWAKE,
at,
null,
),
)
inferredEnd,
)
}
current.quickDraft(at, events = events)
}
fun quickSleepMedication(
@ -933,9 +962,24 @@ class TrainlogRepository(
val activeBefore = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight
val result =
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(
at,
events = current.events + SleepDiaryEvent(
events = boundedEvents + SleepDiaryEvent(
sleepId("sle"),
SleepEventType.FINAL_GET_UP,
at,

View file

@ -544,7 +544,7 @@
<string name="sleep_quick_wake">👁 WAKE</string>
<string name="sleep_quick_get_up">☀ GET UP</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_medication_recorded">%1$s recorded.</string>
<string name="sleep_undo">UNDO</string>

View file

@ -554,7 +554,7 @@
<string name="sleep_quick_wake">👁 RÉVEIL</string>
<string name="sleep_quick_get_up">☀ LEVÉ</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_medication_recorded">%1$s enregistré.</string>
<string name="sleep_undo">ANNULER</string>

View file

@ -142,8 +142,18 @@ class SleepQuickCaptureTest {
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
assertEquals(
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.first().quantity)
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
@ -217,7 +227,7 @@ class SleepQuickCaptureTest {
assertEquals(
listOf("2026-09-24T02:00:00+02:00"),
night.events
.filter { it.type == SleepEventType.NIGHT_GET_UP }
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt },
)
} 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
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
val name = "sleep-no-sensor-" + UUID.randomUUID()

View file

@ -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
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
no capture. After Couché, the first fresh real measurement creates or resumes
one `context_kind=sleep` capture for that entry, with `started_at` equal to the

View file

@ -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 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 |
| 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 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 |
@ -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 |
| 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 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 |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -320,14 +320,15 @@ name/dose/unit snapshot. Full editing remains available later.
### 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 user tries to return to sleep.
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.
The 30-minute end is user-approved product policy, not an inference from BPM or
RR data. Later correction remains available when the actual duration is known.
### Levé
@ -347,11 +348,11 @@ Undo is scoped to that exact action and must not roll back unrelated newer
state.
`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.
The night-time rule is: one tap, one timestamp, then no further required
interaction.
The night-time rule is: one tap, one 30-minute awakening window, then no further
required interaction.
## Sleep + cardio factual timeline
@ -361,7 +362,7 @@ After synchronization, Analyse may superpose factual events and measurements:
- Couché;
- medication intakes;
- wake points;
- structured 30-minute awakening windows;
- Levé;
- BPM;
- RR when supplied;

View file

@ -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
inside the sleep timeline and point/medication markers retain their positions.
The change affects no database schema, synchronization artifact, frozen format,
or Android behavior.
The original Web change affected no database schema, synchronization artifact,
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.

View file

@ -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`
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
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,
and successful undo appends a compensating revision instead of rewriting the
history.
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
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
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
`assembleDebug` succeeded. After the localization cleanup the targeted
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
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,

View file

@ -185,7 +185,7 @@ describe("sleep diary PDF", () => {
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 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");
expect(firstSleep).toBeGreaterThan(-1);

View file

@ -160,12 +160,18 @@ function row(
);
const shade = range.kind === "sleep"
? range.estimated ? 0.88 : 0.75
: range.kind === "awake"
? 0.5
: event?.type === "half_sleep" ? 0.87 : 0.93;
stream.push(
`${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`,
);
: 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(
`${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`,
);
}
if (eventWidth > 24) {
const label = event
? eventLabels[language][event.type]

View file

@ -3133,8 +3133,8 @@ h2 {
stroke-dasharray: 5 3;
}
.heart-rate-awake-band {
fill: rgb(249 226 175 / 14%);
stroke: rgb(249 226 175 / 55%);
fill: rgb(250 179 135 / 18%);
stroke: rgb(250 179 135 / 72%);
stroke-width: 0.7;
vector-effect: non-scaling-stroke;
}