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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue