From c3413957b1b2fcccc0a8f7bf1b65ce0fde571f01 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Tue, 20 Oct 2026 16:42:53 -0700 Subject: [PATCH] feat(sleep): extend a sleep record over time the ring missed (#106) Record editing (#82) could only shorten. A record can now also be pulled earlier or later, to cover time the ring missed. The added minutes are stored as a new SleepStage.ADDED: they count as asleep, but stay out of the stage breakdown and the score. The hypnogram draws them as a hatched "Added by you" band, and Health Connect gets them as STAGE_TYPE_SLEEPING. - editedRecordBlocks (SleepRecordExtension.kt) is the one function that maps a record through an edit. SleepRecordDeletion.edit and the import's edit gate both call it, so a re-sync writes back the edit's blocks with the same startAt values. It re-derives ADDED from the bounds and snaps them onto the record's own minute grid. - Limits: the neighbouring record, 2 h past the ring's own bounds, the 7 PM waking-day turnover, and at least one measured minute kept. The dialog names the limit a pick ran past. At import, a stored extension gives way to any other stored record. - Scores are computed on the minutes the ring measured (measuredAsleepMinutes) in reconcileWakingDay, restateSession and SleepScore.calculate. - Added blocks carry the record's recordStartAt, so a covered gap stops counting as awake between records (#81). A gap closed this way also segments as one night. --- AGENTS.md | 25 ++ .../com/pulseloop/data/SleepRecordDeletion.kt | 85 +++-- .../health/HealthConnectTypeMappings.kt | 3 + .../com/pulseloop/ring/RingDecodedEvent.kt | 10 +- .../service/EventPersistenceSubscriber.kt | 160 +++++----- .../com/pulseloop/service/SleepInsights.kt | 37 ++- .../pulseloop/service/SleepRecordExtension.kt | 228 ++++++++++++++ .../com/pulseloop/ui/screens/SleepScreen.kt | 137 ++++++-- .../pulseloop/data/SleepRecordDeletionTest.kt | 15 - .../service/SleepRecordExtensionTest.kt | 295 ++++++++++++++++++ 10 files changed, 847 insertions(+), 148 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/service/SleepRecordExtension.kt create mode 100644 app/src/test/java/com/pulseloop/service/SleepRecordExtensionTest.kt diff --git a/AGENTS.md b/AGENTS.md index e3182c99..cbe72b3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -951,6 +951,31 @@ things to preserve: The sync's reconcile and `SleepRecordDeletion`'s restate must segment with the same bridge, or a record delete would split a night the next sync merges back. +## An extended record's added minutes are asleep but unstaged (issue #106) + +Editing (#82) can extend a record over time the ring missed, not only shorten it. The added minutes +are stored as `SleepStage.ADDED` blocks — **not** `UNKNOWN`, which is a stage byte the ring did +record and we can't read. + +- **In time asleep, out of everything that judges stages.** `asleepMinutes` already admits them (it + excludes only AWAKE). Every score or stage share uses `measuredAsleepMinutes` / drops `addedMinutes`: + `sleepStageScore` in `reconcileWakingDay` and `SleepRecordDeletion.restateSession`, and + `SleepScore.calculate`. Health Connect gets `STAGE_TYPE_SLEEPING`. The hypnogram draws them as a + hatched band, not a lane. +- **They carry the ring record's `recordStartAt`**, so `sleepRecordRuns` keeps them in that record + and the covered gap stops counting as a between-record waking (#81) with no special case. +- **One function maps a record through an edit**: `editedRecordBlocks` (`SleepRecordExtension.kt`). + `SleepRecordDeletion.edit` and the import's edit gate both call it, so a re-sync writes back the + edit's blocks with the same `startAt`s. It ignores existing ADDED blocks and re-derives them from + the bounds, and snaps the bounds inwards onto the record's own minute grid. Don't hand-build + ADDED blocks anywhere else. +- **Limits** (`recordEditLimits`): back to the previous record's end, forward to the next record's + start, otherwise 2 h past the ring's own bounds, never across the 7 PM waking-day turnover, and a + record must keep one measured minute. At import, a stored extension is also clamped clear of any + other stored record, so the ring's measurement always beats an extension. +- **Known divergence**: the edit path only sees stored blocks. Re-extending over minutes an earlier + edit trimmed off makes them ADDED until the next sync, which restores the ring's real stages there. + ## Long-lived services belong to the process, not the composition (issue #113) **`RingBLEClient`, `RingSyncCoordinator`, `EventPersistenceSubscriber` and the other app-lifetime diff --git a/app/src/main/java/com/pulseloop/data/SleepRecordDeletion.kt b/app/src/main/java/com/pulseloop/data/SleepRecordDeletion.kt index 9c18f311..4a718cae 100644 --- a/app/src/main/java/com/pulseloop/data/SleepRecordDeletion.kt +++ b/app/src/main/java/com/pulseloop/data/SleepRecordDeletion.kt @@ -82,13 +82,18 @@ object SleepRecordDeletion { } /** - * Adjust the declared boundaries of the ring record whose blocks carry [recordStartAt] to - * [newStartAt]..[newEndAt] (issue #82). + * Move the bounds of the ring record whose blocks carry [recordStartAt] to + * [newStartAt]..[newEndAt] (issue #82) — shorter, longer, or one side each (issue #106). * - * Stages inside the new range stay; stages now outside it are removed and drop from session - * totals. The edit is recorded in [MeasurementDeletionDao] so it survives a re-sync. + * Stages outside the new range are removed and drop from session totals. Time inside it that + * the ring didn't measure becomes [SleepStage.ADDED] minutes: asleep, unstaged, unscored. The + * blocks come from [com.pulseloop.service.editedRecordBlocks], the function the import uses to + * re-apply the stored edit, so the next sync of the night writes back exactly these blocks. * - * Returns true when the record was successfully updated. + * The bounds are held to [com.pulseloop.service.recordEditLimits] (the dialog's limits: the + * neighbouring record, two hours outward, the night's turnover) and clear of records in the + * day's other sessions, which the dialog doesn't see. Returns false when nothing changed or the + * new range keeps none of the ring's own minutes. */ suspend fun edit( db: PulseLoopDatabase, @@ -115,39 +120,51 @@ object SleepRecordDeletion { val targetRecordStart = targets.first().recordStartAt.let { if (it > 0L) it else targets.first().startAt } val day = session.date - // Trim each target block to [newStartAt, newEndAt) - val survivingTargets = mutableListOf() - for (b in targets) { - val bStart = b.startAt - val bEnd = b.startAt + b.durationMinutes * 60_000L - val keepStart = maxOf(bStart, newStartAt) - val keepEnd = minOf(bEnd, newEndAt) - - if (keepStart < keepEnd) { - val duration = ((keepEnd - keepStart) / 60_000L).toInt() - if (duration > 0) { - survivingTargets.add( - b.copy( - startAt = keepStart, - durationMinutes = duration, - ) - ) - } - } - } - - // If trimming leaves no stages in this record, delete the record completely - if (survivingTargets.isEmpty()) { - return@withTransaction delete(db, sessionId, recordStartAt) - } + val (start, end) = clampEdit(db, session, blocks, targets, newStartAt, newEndAt) + val edited = com.pulseloop.service.editedRecordBlocks(targets, targetRecordStart, start, end) + ?: return@withTransaction false - db.measurementDeletionDao().recordSleepEdit(day, targetRecordStart, newStartAt, newEndAt) + db.measurementDeletionDao().recordSleepEdit(day, targetRecordStart, start, end) val nonTargets = blocks - targets.toSet() - restateSession(db, session, nonTargets + survivingTargets, bridge) + restateSession(db, session, nonTargets + edited, bridge) true } + /** + * [newStartAt]..[newEndAt] held to the limits the dialog applies, and clear of every block in + * the day's other sessions (issue #106). The dialog already clamps; this is the authority, so a + * stale dialog or another caller can't extend a record over a neighbour. + */ + private suspend fun clampEdit( + db: PulseLoopDatabase, + session: SleepSessionEntity, + blocks: List, + targets: List, + newStartAt: Long, + newEndAt: Long, + ): Pair { + val runs = com.pulseloop.service.sleepRecordRuns(blocks) + val first = targets.minOf { it.startAt } + val index = runs.indexOfFirst { run -> run.blocks.any { it.startAt == first } } + val run = runs.getOrNull(index) ?: com.pulseloop.service.sleepRecordRuns(targets).first() + val limits = com.pulseloop.service.recordEditLimits( + run = run, + previous = runs.getOrNull(index - 1).takeIf { index > 0 }, + next = runs.getOrNull(index + 1).takeIf { index >= 0 }, + wakingDay = session.date, + ) + val start = newStartAt.coerceAtLeast(limits.earliestStart) + val end = newEndAt.coerceAtMost(limits.latestEnd) + val otherSessions = db.sleepSessionDao().ringAllByDay(session.date).filter { it.id != session.id } + val others = if (otherSessions.isEmpty()) emptyList() + else db.sleepStageBlockDao().forSessions(otherSessions.map { it.id }) + val (nightStart, nightEnd) = com.pulseloop.service.nightWindow(session.date) + return com.pulseloop.service.clampEditToNeighbours( + start, end, limits.measuredStart, limits.measuredEnd, others, nightStart, nightEnd, + ) + } + /** The next stamped record boundary after [after], or end-of-night — bounds the legacy fallback. */ private fun nextStampedStart(blocks: List, after: Long): Long = blocks.map { it.recordStartAt }.filter { it > after }.minOrNull() ?: Long.MAX_VALUE @@ -188,6 +205,8 @@ object SleepRecordDeletion { val totalMin = com.pulseloop.service.asleepMinutes(row.blocks) val deepMin = row.blocks.filter { it.stageRaw == SleepStage.DEEP.name } .sumOf { it.durationMinutes } + // Scored on the ring's own minutes, as `reconcileWakingDay` does (issue #106). + val measuredMin = com.pulseloop.service.measuredAsleepMinutes(row.blocks) // Parent session BEFORE its blocks (FK sleep_stage_blocks -> sleep_sessions.id). db.sleepSessionDao().upsert( session.copy( @@ -195,7 +214,7 @@ object SleepRecordDeletion { startAt = row.startAt, endAt = row.endAt, totalMinutes = totalMin, - score = com.pulseloop.service.sleepStageScore(deepMin, totalMin), + score = com.pulseloop.service.sleepStageScore(deepMin, measuredMin), updatedAt = now, ), ) diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt index b6f9c0d2..a1a77bf3 100644 --- a/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt +++ b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt @@ -521,6 +521,9 @@ object HealthConnectTypeMappings { SleepStage.REM.name -> SleepSessionRecord.STAGE_TYPE_REM SleepStage.AWAKE.name -> SleepSessionRecord.STAGE_TYPE_AWAKE SleepStage.UNKNOWN.name -> SleepSessionRecord.STAGE_TYPE_UNKNOWN + // Minutes the wearer added over time the ring missed (issue #106): asleep, but no stage was + // measured, so the generic asleep type rather than any specific stage. + SleepStage.ADDED.name -> SleepSessionRecord.STAGE_TYPE_SLEEPING else -> SleepSessionRecord.STAGE_TYPE_UNKNOWN } diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index 5bf00b16..e8193bcd 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -44,7 +44,15 @@ enum class SpotVital( * Ported from [SleepStage] in PulseModels.swift. */ enum class SleepStage { - LIGHT, DEEP, AWAKE, UNKNOWN, REM; + LIGHT, DEEP, AWAKE, UNKNOWN, REM, + + /** + * Minutes the wearer added to a ring record by editing it over time the ring missed (issue + * #106). Asleep, but unstaged: counted in time asleep, left out of the stage breakdown and the + * score. No decoder ever produces it — only `editedRecordBlocks` (SleepRecordExtension.kt) does. + * Deliberately not [UNKNOWN], which is a stage byte the ring *did* record and we can't read. + */ + ADDED; companion object { /** diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index dbeb21dc..8cd61fc3 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -597,21 +597,15 @@ class EventPersistenceSubscriber( return } - // Sleep record edit gate (issue #82): adjust boundaries if user edited this record + // Sleep record edit gate (issue #82, #106): the user's bounds for this record, re-applied + // inside the transaction below — trimming *and* re-creating any minutes they added over + // time the ring missed, through the same function the edit itself used. val edit = db.measurementDeletionDao().getSleepEdit(dayStart, ts) ?: db.measurementDeletionDao().getSleepEdit(origDayStart, ts) - if (edit != null) { - val recEnd = effectiveTs + effectiveStages.size * 60_000L - val trimmedStart = maxOf(effectiveTs, edit.newStartAt) - val trimmedEnd = minOf(recEnd, edit.newEndAt) - if (trimmedStart >= trimmedEnd) return - val startIndex = ((trimmedStart - effectiveTs) / 60_000L).toInt() - val endIndex = ((trimmedEnd - effectiveTs) / 60_000L).toInt() - effectiveTs = trimmedStart - effectiveStages = effectiveStages.subList(startIndex, endIndex) - } - db.withTransaction { upsertSleepSessionAtomic(effectiveTs, effectiveStages, completeSession, originalRecordStart = ts) } + db.withTransaction { + upsertSleepSessionAtomic(effectiveTs, effectiveStages, completeSession, originalRecordStart = ts, edit = edit) + } } private suspend fun upsertSleepSessionAtomic( @@ -619,28 +613,47 @@ class EventPersistenceSubscriber( stages: List, completeSession: Boolean, originalRecordStart: Long = ts, + edit: com.pulseloop.data.dao.SleepRecordEditBounds? = null, ) { // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so // a night that starts before midnight lands under the morning of waking instead of being // split into two sessions at midnight. Matches the iOS reference // (PulseEventBus.persistSleepTimeline + Calendar.wakingDay(forSleepStart:)). val dayStart = com.pulseloop.util.TimeUtil.wakingDayLocal(ts) - val packetEnd = ts + stages.size * 60_000L + val ringEnd = ts + stages.size * 60_000L // Include legacy rows keyed to the wrong day if they overlap this packet. Reconciliation // re-points their surviving blocks to the correct waking day. - val overlapping = db.sleepSessionDao().ringOverlapping(ts, packetEnd) + val overlapping = db.sleepSessionDao().ringOverlapping(ts, ringEnd) val existing = (db.sleepSessionDao().ringAllByDay(dayStart) + overlapping).distinctBy { it.id } val existingBlocks = if (existing.isEmpty()) emptyList() else db.sleepStageBlockDao().forSessions(existing.map { it.id }) + var recordBlocks = buildStageBlocks("", ts, stages, recordStartAt = originalRecordStart) + if (edit != null) { + // Re-apply the user's edit (issue #82, #106) with `editedRecordBlocks`, the function + // `SleepRecordDeletion.edit` used, so this sync reproduces the edit's blocks — the same + // `startAt`s, so tombstones and the restate stay idempotent. Added minutes give way to + // any other record already stored: the ring's measurement beats an extension. + val (nightStart, nightEnd) = nightWindow(dayStart) + val (start, end) = clampEditToNeighbours( + edit.newStartAt, edit.newEndAt, ts, ringEnd, + others = existingBlocks.filter { it.recordStartAt != originalRecordStart }, + nightStart = nightStart, nightEnd = nightEnd, + ) + recordBlocks = editedRecordBlocks(recordBlocks, originalRecordStart, start, end) ?: return + } + // The interval this packet is authoritative for: the record as edited, added minutes and all. + val packetStart = recordBlocks.minOf { it.startAt } + val packetEnd = recordBlocks.maxOf { it.startAt + it.durationMinutes * 60_000L } + // YCBT complete records are authoritative for the ring session they describe, including // shortened revisions. Packet-based families replace only the packet interval. In both // cases the unaffected blocks remain available for SleepSegmentation to preserve // separate naps — and, for a complete record, the *other* ring sessions of the same night // (issue #63): see [completeSessionSurvivors] for why "the session it describes" is a // contiguous run of blocks and not every block of every row the packet touches. - val replacements = buildStageBlocks("", ts, stages, recordStartAt = originalRecordStart) + val replacements = recordBlocks // Tombstone check (issue #78): a deleted ring record's blocks come back with the same // `startAt` values on every re-send of that night, so the re-derive must drop them // here or `SleepRecordDeletion`'s work lasts exactly one sync. @@ -651,12 +664,12 @@ class EventPersistenceSubscriber( } val dayBlocks = replaceOverlappingSleepBlocks( existing = if (completeSession) { - completeSessionSurvivors(existingBlocks, ts, packetEnd) + completeSessionSurvivors(existingBlocks, packetStart, packetEnd) } else { existingBlocks }, replacements = replacements, - replacementStart = ts, + replacementStart = packetStart, replacementEnd = packetEnd, ) @@ -736,7 +749,8 @@ class EventPersistenceSubscriber( val deepMin = seg.blocks .filter { it.stageRaw == SleepStage.DEEP.name } .sumOf { it.durationMinutes } - val score = computeSleepScore(deepMin, totalMin) + // Scored on the minutes the ring measured: added ones are asleep but unstaged (#106). + val score = computeSleepScore(deepMin, measuredAsleepMinutes(seg.blocks)) // Parent session BEFORE its blocks (FK sleep_stage_blocks -> sleep_sessions.id). db.sleepSessionDao().upsert( @@ -768,61 +782,6 @@ class EventPersistenceSubscriber( available.forEach { db.sleepSessionDao().deleteById(it.id) } } - /** - * Build SleepStageBlockEntity entries with run-length encoding. - * Consecutive minutes of the same stage are merged into one block. - * - * Every block carries [startTs] as its `recordStartAt` — the declared start of the ring record - * these stages arrived in (issue #68). This is the only point at which that is known: the night - * is merged into one session immediately afterwards, and the record boundary is not recoverable - * from the stored timeline, because this ring reopens a record a single minute after closing - * one and that is the same width as the minute-grid rounding seam between two blocks of the - * same record. - */ - private fun buildStageBlocks( - sessionId: String, - startTs: Long, - stages: List, - recordStartAt: Long = startTs, - ): List { - if (stages.isEmpty()) return emptyList() - val blocks = mutableListOf() - var currentStage = stages[0] - var blockStart = startTs - var blockMinute = 0 - var duration = 1 - - for (i in 1 until stages.size) { - val stage = stages[i] - if (stage == currentStage) { - duration++ - } else { - blocks.add(SleepStageBlockEntity( - sessionId = sessionId, - startAt = blockStart, - startMinute = blockMinute, - durationMinutes = duration, - stageRaw = currentStage.name, - recordStartAt = recordStartAt, - )) - currentStage = stage - blockStart = startTs + i * 60_000L - blockMinute = i - duration = 1 - } - } - // Final block - blocks.add(SleepStageBlockEntity( - sessionId = sessionId, - startAt = blockStart, - startMinute = blockMinute, - durationMinutes = duration, - stageRaw = currentStage.name, - recordStartAt = recordStartAt, - )) - return blocks - } - /** * Sleep quality score (0-100) based on deep sleep ratio. * Medical research: optimal deep sleep = 15-25% of total. @@ -890,6 +849,61 @@ internal fun spotReadingsMatching( window: Long = EventPersistenceSubscriber.SPOT_MATCH_MS, ): List = ours.filter { kotlin.math.abs(it - historyAt) <= window } +/** + * Build SleepStageBlockEntity entries with run-length encoding. + * Consecutive minutes of the same stage are merged into one block. + * + * Every block carries [startTs] as its `recordStartAt` — the declared start of the ring record + * these stages arrived in (issue #68). This is the only point at which that is known: the night + * is merged into one session immediately afterwards, and the record boundary is not recoverable + * from the stored timeline, because this ring reopens a record a single minute after closing + * one and that is the same width as the minute-grid rounding seam between two blocks of the + * same record. + */ +internal fun buildStageBlocks( + sessionId: String, + startTs: Long, + stages: List, + recordStartAt: Long = startTs, +): List { + if (stages.isEmpty()) return emptyList() + val blocks = mutableListOf() + var currentStage = stages[0] + var blockStart = startTs + var blockMinute = 0 + var duration = 1 + + for (i in 1 until stages.size) { + val stage = stages[i] + if (stage == currentStage) { + duration++ + } else { + blocks.add(SleepStageBlockEntity( + sessionId = sessionId, + startAt = blockStart, + startMinute = blockMinute, + durationMinutes = duration, + stageRaw = currentStage.name, + recordStartAt = recordStartAt, + )) + currentStage = stage + blockStart = startTs + i * 60_000L + blockMinute = i + duration = 1 + } + } + // Final block + blocks.add(SleepStageBlockEntity( + sessionId = sessionId, + startAt = blockStart, + startMinute = blockMinute, + durationMinutes = duration, + stageRaw = currentStage.name, + recordStartAt = recordStartAt, + )) + return blocks +} + internal fun historyMeasurementId(kind: MeasurementKind, timestamp: Long): String = "history:${kind.key}:$timestamp" diff --git a/app/src/main/java/com/pulseloop/service/SleepInsights.kt b/app/src/main/java/com/pulseloop/service/SleepInsights.kt index 48aae379..dcd7eed1 100644 --- a/app/src/main/java/com/pulseloop/service/SleepInsights.kt +++ b/app/src/main/java/com/pulseloop/service/SleepInsights.kt @@ -75,7 +75,13 @@ object SleepScore { // label awake at all" heuristic are judged against it, since both are about time in bed — // and judged against asleep time the coverage test below would be true for every ring, // handing a ring that never labels awake the full awake sub-score for nothing. - val total = if (session.totalMinutes > 0) session.totalMinutes.toDouble() else 0.0 + // + // Minutes the wearer added over time the ring missed (issue #106) are asleep but carry no + // stage, so the score judges the night the ring measured: they leave `total` (they would + // dilute every stage share) and the coverage test, and stay in `span`, which held them as a + // between-record gap before the edit and still does. + val added = addedMinutes(blocks) + val total = if (session.totalMinutes > 0) (session.totalMinutes - added).coerceAtLeast(0).toDouble() else 0.0 val span = session.spanMinutes.toDouble().takeIf { it > 0 } ?: total // stageRaw is persisted as the SleepStage enum name (uppercase) — match it exactly. // Some rings report REM in big-data sleep; the score model has no REM band, so fold @@ -85,7 +91,8 @@ object SleepScore { val deep = minutesFor(SleepStage.DEEP, SleepStage.REM) val light = minutesFor(SleepStage.LIGHT) val awake = minutesFor(SleepStage.AWAKE) - val coveredStageMin = blocks.sumOf { it.durationMinutes.toDouble() } + val coveredStageMin = blocks.filterNot { it.stageRaw == SleepStage.ADDED.name } + .sumOf { it.durationMinutes.toDouble() } val hasAwakeSignal = blocks.any { it.stageRaw == SleepStage.AWAKE.name } || awake > 0 || (span > 0 && coveredStageMin >= span * 0.95) @@ -141,6 +148,22 @@ fun asleepMinutes(blocks: List): Int = .sumOf { it.durationMinutes } .coerceAtLeast(0) +/** + * Minutes the wearer added to a record over time the ring missed (issue #106) — asleep, unstaged. + * They are inside [asleepMinutes] (the AWAKE-exclusion rule already admits them) and outside + * everything that judges stages: subtract them wherever a stage share or score is computed. + */ +fun addedMinutes(blocks: List): Int = + blocks.filter { it.stageRaw == SleepStage.ADDED.name }.sumOf { it.durationMinutes } + +/** + * The asleep minutes the ring itself measured: [asleepMinutes] less [addedMinutes]. The + * denominator for [sleepStageScore], so a night the wearer extended scores exactly as the ring's + * own record would have (issue #106). + */ +fun measuredAsleepMinutes(blocks: List): Int = + (asleepMinutes(blocks) - addedMinutes(blocks)).coerceAtLeast(0) + /** * Awake minutes for a session, including the short gaps between its ring records (issue #81). * @@ -222,7 +245,8 @@ fun hypnogramBlocks(blocks: List): List 0L }?.recordStartAt ?: startAt val asleepMinutes: Int get() = asleepMinutes(blocks) + /** Minutes the wearer added over time the ring missed (issue #106); part of [asleepMinutes]. */ + val addedMinutes: Int get() = addedMinutes(blocks) + private val measured get() = blocks.filterNot { it.stageRaw == SleepStage.ADDED.name } + /** Where the ring's own recording starts and ends inside this run, without the added minutes — + * the bounds an extension is measured from (issue #106). The run's own when nothing is measured. */ + val measuredStartAt: Long get() = measured.minOfOrNull { it.startAt } ?: startAt + val measuredEndAt: Long get() = measured.maxOfOrNull { it.startAt + it.durationMinutes * 60_000L } ?: endAt val spanMinutes: Int get() = ((endAt - startAt) / 60_000L).toInt().coerceAtLeast(0) } diff --git a/app/src/main/java/com/pulseloop/service/SleepRecordExtension.kt b/app/src/main/java/com/pulseloop/service/SleepRecordExtension.kt new file mode 100644 index 00000000..3ada1ae9 --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/SleepRecordExtension.kt @@ -0,0 +1,228 @@ +package com.pulseloop.service + +import com.pulseloop.data.entity.SleepStageBlockEntity +import com.pulseloop.ring.SleepStage +import com.pulseloop.util.TimeUtil +import java.time.Instant +import java.time.ZoneId + +/** + * Editing a ring sleep record's bounds, including **extending** it over time the ring missed + * (issue #106). Pure: the edit path (`SleepRecordDeletion.edit`) and the import path + * (`EventPersistenceSubscriber`, which re-applies a stored edit on every re-sync of the night) both + * map a record through [editedRecordBlocks], so a re-sync produces exactly the blocks the edit did. + * + * The reporter's ring recorded 12:51–5:44 and 6:56–8:07, but they were asleep again from about + * 5:46; the app counted the hole as 1 h 12 awake (#81). Editing (#82) could only shorten. Minutes an + * extension adds are stored as [SleepStage.ADDED] blocks — **asleep but unstaged**: in time asleep, + * out of the stage breakdown and the score, drawn as their own band on the hypnogram. They carry + * the ring record's `recordStartAt`, so they belong to that record for `sleepRecordRuns`, and the + * gap they cover stops counting as a between-record waking with no further change. + */ + +/** How far a record may be extended beyond the ring's own bounds where no neighbour limits it. */ +const val MAX_OUTWARD_EXTENSION_MINUTES = 120 + +private const val MINUTE_MS = 60_000L + +/** + * The first point on the record's minute grid at or after [t]. A ring record's stages sit on a + * grid anchored at its declared start (`buildStageBlocks`), and its blocks' `startAt` values are + * the identity tombstones key on, so an edit snaps to that grid rather than to wall-clock minutes. + */ +internal fun gridCeil(t: Long, origin: Long): Long = + origin + Math.floorDiv(t - origin + MINUTE_MS - 1, MINUTE_MS) * MINUTE_MS + +/** The last point on the record's minute grid at or before [t]. See [gridCeil]. */ +internal fun gridFloor(t: Long, origin: Long): Long = + origin + Math.floorDiv(t - origin, MINUTE_MS) * MINUTE_MS + +/** + * One record's blocks after an edit to [editStart]..[editEnd] — the single function both paths + * use (issue #106). + * + * [blocks] are the record's blocks: the import passes the ring's freshly built ones, the edit path + * the stored ones. Any [SleepStage.ADDED] block among them is ignored — a previous extension is + * re-derived from the new bounds, never kept — so editing back inside the ring's bounds removes the + * added minutes, and re-applying the same edit to its own output is a no-op. + * + * The bounds snap inwards onto the grid anchored at [gridOrigin] (the record's declared start): + * the start up, the end down, so the result never reaches past a limit the caller clamped to. + * Measured blocks are trimmed to the bounds exactly as a shortening always did; time outside the + * ring's own measured span becomes one ADDED block on each side that needs one. + * + * Null when the bounds keep none of the ring's own minutes — a record made only of added time has + * nothing to anchor it to, so the edit is refused rather than inventing a night. + */ +fun editedRecordBlocks( + blocks: List, + gridOrigin: Long, + editStart: Long, + editEnd: Long, +): List? { + val measured = blocks.filterNot { it.stageRaw == SleepStage.ADDED.name }.sortedBy { it.startAt } + if (measured.isEmpty()) return null + val start = gridCeil(editStart, gridOrigin) + val end = gridFloor(editEnd, gridOrigin) + if (start >= end) return null + + val kept = measured.mapNotNull { b -> + val keepStart = maxOf(b.startAt, start) + val keepEnd = minOf(b.startAt + b.durationMinutes * MINUTE_MS, end) + val minutes = ((keepEnd - keepStart) / MINUTE_MS).toInt() + if (minutes > 0) b.copy(startAt = keepStart, durationMinutes = minutes) else null + } + if (kept.isEmpty()) return null + + val template = measured.first() + val measuredStart = template.startAt + val measuredEnd = measured.maxOf { it.startAt + it.durationMinutes * MINUTE_MS } + fun added(from: Long, until: Long): SleepStageBlockEntity? { + val minutes = ((until - from) / MINUTE_MS).toInt() + if (minutes <= 0) return null + return SleepStageBlockEntity( + sessionId = template.sessionId, + startAt = from, + startMinute = 0, // re-keyed against the session start by whoever writes the row + durationMinutes = minutes, + stageRaw = SleepStage.ADDED.name, + recordStartAt = template.recordStartAt, + ) + } + return listOfNotNull(if (start < measuredStart) added(start, measuredStart) else null) + + kept + + listOfNotNull(if (end > measuredEnd) added(measuredEnd, end) else null) +} + +/** Why an edit bound stops where it does — what the dialog names when a pick goes past it. */ +enum class RecordEditLimit { + /** The previous record of the same night ends here. */ + PREVIOUS_RECORD, + /** The next record of the same night starts here. */ + NEXT_RECORD, + /** [MAX_OUTWARD_EXTENSION_MINUTES] past the ring's own bounds, before the night's first record + * or after its last. */ + OUTWARD_CAP, + /** The waking day's 7 PM turnover: further would move the record into another night. */ + NIGHT_BOUNDARY, + /** The record must keep at least one minute the ring measured. */ + RING_MINUTES, +} + +/** + * How far one record's bounds may move (issue #106). The start may go back to the previous + * record's end (no cap: that is the gap the ring missed, however long the Bedtime bridge let it + * be) or, for the night's first record, [MAX_OUTWARD_EXTENSION_MINUTES] before the ring's own + * start; the end likewise forward. Never across the waking day's 7 PM turnover. Shortening is + * limited only by keeping one measured minute. + */ +data class RecordEditLimits( + val gridOrigin: Long, + val earliestStart: Long, + val earliestStartLimit: RecordEditLimit, + val latestEnd: Long, + val latestEndLimit: RecordEditLimit, + /** The ring's own measured span inside the record, which a shortening must keep a minute of. */ + val measuredStart: Long, + val measuredEnd: Long, +) + +fun recordEditLimits( + run: SleepRecordRun, + previous: SleepRecordRun?, + next: SleepRecordRun?, + wakingDay: Long, + zone: ZoneId = ZoneId.systemDefault(), +): RecordEditLimits { + val origin = run.recordStartAt + val measuredStart = run.measuredStartAt + val measuredEnd = run.measuredEndAt + val cap = MAX_OUTWARD_EXTENSION_MINUTES * MINUTE_MS + val (nightStart, nightEnd) = nightWindow(wakingDay, zone) + + val startCandidates = listOf( + if (previous != null) gridCeil(previous.endAt, origin) to RecordEditLimit.PREVIOUS_RECORD + else gridCeil(measuredStart - cap, origin) to RecordEditLimit.OUTWARD_CAP, + gridCeil(nightStart, origin) to RecordEditLimit.NIGHT_BOUNDARY, + ) + val endCandidates = listOf( + if (next != null) gridFloor(next.startAt, origin) to RecordEditLimit.NEXT_RECORD + else gridFloor(measuredEnd + cap, origin) to RecordEditLimit.OUTWARD_CAP, + gridFloor(nightEnd, origin) to RecordEditLimit.NIGHT_BOUNDARY, + ) + // A limit never cuts into the record as it already stands (overlapping legacy rows). + val (earliest, earliestWhy) = startCandidates.maxBy { it.first } + val (latest, latestWhy) = endCandidates.minBy { it.first } + return RecordEditLimits( + gridOrigin = origin, + earliestStart = minOf(earliest, run.startAt), + earliestStartLimit = earliestWhy, + latestEnd = maxOf(latest, run.endAt), + latestEndLimit = latestWhy, + measuredStart = measuredStart, + measuredEnd = measuredEnd, + ) +} + +/** A picked bound after the limits: [at] is what to use, [limit] is set when the pick went past one. */ +data class ClampedEditPick(val at: Long, val limit: RecordEditLimit?) + +/** + * A start picked in the dialog (a whole minute) placed on the record's grid and held inside + * [limits]. The grid point inside the picked minute is the one the button then shows, so picking + * the minute already displayed is never a change and never a message (issue #106, #116). + */ +fun clampStartPick(picked: Long, limits: RecordEditLimits): ClampedEditPick { + val at = gridCeil(picked - picked % MINUTE_MS, limits.gridOrigin) + val lastStart = gridFloor(limits.measuredEnd - MINUTE_MS, limits.gridOrigin) + return when { + at < limits.earliestStart -> ClampedEditPick(limits.earliestStart, limits.earliestStartLimit) + at > lastStart -> ClampedEditPick(lastStart, RecordEditLimit.RING_MINUTES) + else -> ClampedEditPick(at, null) + } +} + +/** The end-side twin of [clampStartPick]. */ +fun clampEndPick(picked: Long, limits: RecordEditLimits): ClampedEditPick { + val at = gridCeil(picked - picked % MINUTE_MS, limits.gridOrigin) + val firstEnd = gridCeil(limits.measuredStart + MINUTE_MS, limits.gridOrigin) + return when { + at > limits.latestEnd -> ClampedEditPick(limits.latestEnd, limits.latestEndLimit) + at < firstEnd -> ClampedEditPick(firstEnd, RecordEditLimit.RING_MINUTES) + else -> ClampedEditPick(at, null) + } +} + +/** + * The span of waking day [wakingDay] (local midnight): from 7 PM the evening before to 7 PM on the + * day — `TimeUtil.wakingDayLocal`'s turnover, so a record extended inside it keeps its date. + */ +internal fun nightWindow(wakingDay: Long, zone: ZoneId = ZoneId.systemDefault()): Pair { + val day = Instant.ofEpochMilli(wakingDay).atZone(zone).toLocalDate() + val hour = TimeUtil.SLEEP_EVENING_BOUNDARY_HOUR + val start = day.minusDays(1).atTime(hour, 0).atZone(zone).toInstant().toEpochMilli() + val end = day.atTime(hour, 0).atZone(zone).toInstant().toEpochMilli() + return start to end +} + +/** + * Hold edit bounds clear of every block that isn't this record's — the import's guard, and the edit + * path's against records in *other* sessions of the day, which the dialog doesn't see. The ring's + * measurement always beats an extension: whichever arrives first, the added minutes give way + * (`replaceOverlappingSleepBlocks` trims them when a neighbour arrives later). + */ +internal fun clampEditToNeighbours( + editStart: Long, + editEnd: Long, + measuredStart: Long, + measuredEnd: Long, + others: List, + nightStart: Long, + nightEnd: Long, +): Pair { + val before = others.map { it.startAt + it.durationMinutes * MINUTE_MS }.filter { it <= measuredStart } + val after = others.map { it.startAt }.filter { it >= measuredEnd } + val lo = maxOf(before.maxOrNull() ?: Long.MIN_VALUE, nightStart) + val hi = minOf(after.minOrNull() ?: Long.MAX_VALUE, nightEnd) + return maxOf(editStart, minOf(lo, measuredStart)) to minOf(editEnd, maxOf(hi, measuredEnd)) +} diff --git a/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt index 4558f266..e17e39b5 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt @@ -50,6 +50,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.pulseloop.data.entity.SleepSessionEntity import com.pulseloop.data.entity.SleepStageBlockEntity +import com.pulseloop.ring.SleepStage +import androidx.compose.ui.graphics.drawscope.clipRect import com.pulseloop.service.SleepBar import com.pulseloop.service.SleepFormat import com.pulseloop.service.SleepInsights @@ -482,6 +484,10 @@ private fun SleepHypnogram( val sorted = remember(blocks) { blocks.filter { it.durationMinutes > 0 && it.stageRaw != "UNKNOWN" }.sortedBy { it.startMinute } } + // Minutes the wearer added over time the ring missed (issue #106) have no lane — no stage was + // measured — so they are drawn as a hatched band across the asleep lanes instead of a step, and + // the step line simply doesn't connect into or out of them. + fun isAdded(stage: String) = stage == SleepStage.ADDED.name // The x axis is wall-clock time, so it is scaled by the session's SPAN, never by its duration. // Since issue #63 those are different numbers — `totalMinutes` is time asleep and excludes the // awake stretches and the gap between a split night's two records, so scaling by it would @@ -529,7 +535,8 @@ private fun SleepHypnogram( } fun readoutText(block: SleepStageBlockEntity): String { - val stage = block.stageRaw.replaceFirstChar { it.uppercase() } + val stage = if (isAdded(block.stageRaw)) "Added by you" + else block.stageRaw.replaceFirstChar { it.uppercase() } val startTime = clockTime(startTs + block.startMinute * 60_000L) val endTime = clockTime(startTs + (block.startMinute + block.durationMinutes) * 60_000L) return "$stage · $startTime – $endTime" @@ -598,10 +605,38 @@ private fun SleepHypnogram( Canvas(Modifier.fillMaxSize()) { if (sorted.isEmpty()) return@Canvas + // Added-by-you bands first, under everything else (issue #106): muted fill and + // diagonal hatching from the REM lane to the DEEP lane. + sorted.filter { isAdded(it.stageRaw) }.forEach { block -> + val startX = xForMinute(block.startMinute, size.width) + val endX = xForMinute(block.startMinute + block.durationMinutes, size.width) + .coerceAtLeast(startX + 1f) + val top = size.height * (laneFrac["REM"] ?: 0.38f) + val bottom = size.height * (laneFrac["DEEP"] ?: 0.85f) + drawRect( + color = ADDED_BAND_COLOR.copy(alpha = 0.14f), + topLeft = Offset(startX, top), + size = androidx.compose.ui.geometry.Size(endX - startX, bottom - top), + ) + clipRect(left = startX, top = top, right = endX, bottom = bottom) { + val step = 7.dp.toPx() + var x = startX - (bottom - top) + while (x < endX) { + drawLine( + color = ADDED_BAND_COLOR.copy(alpha = 0.45f), + start = Offset(x, bottom), end = Offset(x + (bottom - top), top), + strokeWidth = 1.dp.toPx(), + ) + x += step + } + } + } + // Dashed vertical connectors between consecutive blocks. for (i in 1 until sorted.size) { val prev = sorted[i - 1] val cur = sorted[i] + if (isAdded(prev.stageRaw) || isAdded(cur.stageRaw)) continue val cx = xForMinute(cur.startMinute, size.width) drawLine( color = Color(0xFFD2CDFF).copy(alpha = 0.46f), @@ -613,7 +648,7 @@ private fun SleepHypnogram( ) } // Horizontal segment per block: soft halo underlay + solid line. - sorted.forEach { block -> + sorted.filterNot { isAdded(it.stageRaw) }.forEach { block -> val y = laneY(block.stageRaw, size.height) val startX = xForMinute(block.startMinute, size.width) val endX = xForMinute(block.startMinute + block.durationMinutes, size.width) @@ -710,9 +745,26 @@ private fun SleepHypnogram( ) { ticks.forEach { Text(it, fontSize = 10.sp, color = PulseColors.textMuted) } } + if (sorted.any { isAdded(it.stageRaw) }) { + Row( + Modifier.padding(start = plotInsets.x), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box(Modifier.size(width = 10.dp, height = 6.dp).background(ADDED_BAND_COLOR.copy(alpha = 0.45f))) + Text( + "Added by you · asleep, no stage measured", + fontSize = 10.sp, color = PulseColors.textMuted, + ) + } + } } } +/** The hatched band for minutes the wearer added over time the ring missed (issue #106): a + * neutral grey-violet, so it never reads as one of the four measured stage colours. */ +private val ADDED_BAND_COLOR = Color(0xFF9A97B5) + /** Three side-by-side stage duration cards (SleepStageSummaryCardsView in Swift). */ @Composable private fun SleepStageSummaryCards(prefix: String = "", deep: String, light: String, awake: String) { @@ -1032,6 +1084,15 @@ private fun SleepRecordsCard( fontSize = 12.sp, color = PulseColors.textMuted, ) + // Minutes the wearer added over time the ring missed (issue #106). + if (run.addedMinutes > 0) { + Text( + "Includes ${SleepFormat.duration(run.addedMinutes)} added by you, " + + "not staged or scored", + fontSize = 12.sp, + color = PulseColors.textMuted, + ) + } } // Edit start/end boundaries (issue #82). IconButton(onClick = { editingRun = run }) { @@ -1072,8 +1133,17 @@ private fun SleepRecordsCard( } editingRun?.let { run -> + val index = runs.indexOf(run) SleepRecordEditDialog( run = run, + limits = remember(run, runs, session.date) { + com.pulseloop.service.recordEditLimits( + run = run, + previous = runs.getOrNull(index - 1), + next = runs.getOrNull(index + 1), + wakingDay = session.date, + ) + }, onDismiss = { editingRun = null }, onSave = { newStart, newEnd -> editingRun = null @@ -1123,6 +1193,7 @@ private fun SleepRecordsCard( @Composable private fun SleepRecordEditDialog( run: com.pulseloop.service.SleepRecordRun, + limits: com.pulseloop.service.RecordEditLimits, onDismiss: () -> Unit, onSave: (newStartAt: Long, newEndAt: Long) -> Unit, ) { @@ -1130,9 +1201,8 @@ private fun SleepRecordEditDialog( var currentStart by remember { mutableStateOf(run.startAt) } var currentEnd by remember { mutableStateOf(run.endAt) } var pickingTime by remember { mutableStateOf(null) } // "start" or "end" - // Set when the last picked time fell outside the record and was pulled back to its edge, which - // otherwise looks like the edit didn't register (issue #106). - var pickedOutside by remember { mutableStateOf(false) } + // The limit the last pick ran past, named rather than silently snapped back to (issue #106). + var limitMessage by remember { mutableStateOf(null) } AlertDialog( onDismissRequest = onDismiss, @@ -1140,7 +1210,8 @@ private fun SleepRecordEditDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "Adjust start or end time to trim this record. Stages outside the new range will be removed.", + "Trim this record, or extend it over time the ring missed. Added time counts " + + "as asleep but has no stage, so it stays out of the stage breakdown and score.", style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = PulseColors.textMuted, ) @@ -1161,13 +1232,8 @@ private fun SleepRecordEditDialog( Text("End: ${SleepFormat.clockTime(currentEnd)}") } } - if (pickedOutside) { - Text( - "This record runs ${SleepFormat.clockTime(run.startAt)} – " + - "${SleepFormat.clockTime(run.endAt)}. You can only shorten it.", - color = PulseColors.danger, - fontSize = 12.sp, - ) + limitMessage?.let { + Text(it, color = PulseColors.danger, fontSize = 12.sp) } if (currentStart >= currentEnd) { Text( @@ -1206,13 +1272,21 @@ private fun SleepRecordEditDialog( text = { TimePicker(state = timeState) }, confirmButton = { TextButton(onClick = { - val picked = resolveAdjustedTime(targetTs, run.startAt, run.endAt, timeState.hour, timeState.minute, zone) - val resolved = picked.coerceIn(run.startAt, run.endAt) - pickedOutside = isOutsideRecord(picked, run.startAt, run.endAt) + // Resolve the date against the whole span the record may cover, so 11:30 PM + // picked for a record starting 12:51 AM lands the evening before. + val picked = resolveAdjustedTime( + targetTs, limits.earliestStart, limits.latestEnd, timeState.hour, timeState.minute, zone, + ) + val clamped = if (which == "start") { + com.pulseloop.service.clampStartPick(picked, limits) + } else { + com.pulseloop.service.clampEndPick(picked, limits) + } + limitMessage = clamped.limit?.let { editLimitMessage(it, which == "start", clamped.at, limits) } if (which == "start") { - currentStart = resolved + currentStart = clamped.at } else { - currentEnd = resolved + currentEnd = clamped.at } pickingTime = null }) { Text("Done") } @@ -1225,12 +1299,29 @@ private fun SleepRecordEditDialog( } /** - * Whether a time picked in the edit dialog falls outside the record, by more than the seconds the - * picker can't show: the record's own start and end carry seconds, so picking the minute shown on - * the button must not count as outside (issue #106). + * The dialog's line for a pick that ran past [limit], naming the limit (issue #106). [at] is where + * the pick was held — the previous record's end, the earliest start the cap allows, and so on. */ -internal fun isOutsideRecord(picked: Long, recordStart: Long, recordEnd: Long): Boolean = - picked < recordStart - recordStart % 60_000L || picked >= recordEnd - recordEnd % 60_000L + 60_000L +internal fun editLimitMessage( + limit: com.pulseloop.service.RecordEditLimit, + isStart: Boolean, + at: Long, + limits: com.pulseloop.service.RecordEditLimits, +): String { + val time = SleepFormat.clockTime(at) + return when (limit) { + com.pulseloop.service.RecordEditLimit.PREVIOUS_RECORD -> "The previous record ends at $time." + com.pulseloop.service.RecordEditLimit.NEXT_RECORD -> "The next record starts at $time." + com.pulseloop.service.RecordEditLimit.OUTWARD_CAP -> + "A record can be extended by up to ${com.pulseloop.service.MAX_OUTWARD_EXTENSION_MINUTES / 60} " + + "hours, so ${if (isStart) "it can start at $time at the earliest" else "it can end at $time at the latest"}." + com.pulseloop.service.RecordEditLimit.NIGHT_BOUNDARY -> + "${if (isStart) "Starting" else "Ending"} past $time would move it into another night." + com.pulseloop.service.RecordEditLimit.RING_MINUTES -> + "Keep at least a minute of what the ring recorded " + + "(${SleepFormat.clockTime(limits.measuredStart)} – ${SleepFormat.clockTime(limits.measuredEnd)})." + } +} /** * Resolves the epoch millis for an adjusted time within/near a sleep record [recordStart]..[recordEnd]. diff --git a/app/src/test/java/com/pulseloop/data/SleepRecordDeletionTest.kt b/app/src/test/java/com/pulseloop/data/SleepRecordDeletionTest.kt index f1517008..22e085cc 100644 --- a/app/src/test/java/com/pulseloop/data/SleepRecordDeletionTest.kt +++ b/app/src/test/java/com/pulseloop/data/SleepRecordDeletionTest.kt @@ -296,19 +296,4 @@ class SleepRecordDeletionTest { assertEquals(2, targets.size) assertEquals(listOf(b1.startAt, b2.startAt), targets.map { it.startAt }) } - - // ── Issue #106: a picked time outside the record says so ───────────── - - @Test - fun `a picked time outside the record is reported, the record's own minutes are not`() { - val start = 1_791_766_260_000L + 13_000L // a record starting at :13 s - val end = start + 293 * 60_000L - val outside = { picked: Long -> com.pulseloop.ui.screens.isOutsideRecord(picked, start, end) } - // The minutes shown on the buttons, picked back, are inside despite the record's seconds. - assertFalse(outside(start - 13_000L)) - assertFalse(outside(end - end % 60_000L)) - // A minute earlier than the start, or later than the end, is outside. - assertTrue(outside(start - 13_000L - 60_000L)) - assertTrue(outside(end - end % 60_000L + 60_000L)) - } } diff --git a/app/src/test/java/com/pulseloop/service/SleepRecordExtensionTest.kt b/app/src/test/java/com/pulseloop/service/SleepRecordExtensionTest.kt new file mode 100644 index 00000000..dc737cb2 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/SleepRecordExtensionTest.kt @@ -0,0 +1,295 @@ +package com.pulseloop.service + +import androidx.health.connect.client.records.SleepSessionRecord +import com.pulseloop.data.entity.SleepSessionEntity +import com.pulseloop.data.entity.SleepStageBlockEntity +import com.pulseloop.health.HealthConnectTypeMappings +import com.pulseloop.ring.SleepStage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneId +import java.time.ZonedDateTime + +/** + * Extending a sleep record over time the ring missed (issue #106). + * + * The reporter's night: the ring recorded 12:51–5:44 and 6:56–8:07, they were asleep again from + * about 5:46, and the app counted the hole as 1 h 12 awake. The added minutes are asleep but + * unstaged — in time asleep, out of the breakdown and the score — and the edit path and the import + * path both go through [editedRecordBlocks], so a re-sync writes back exactly what the edit did. + */ +class SleepRecordExtensionTest { + + private val zone = ZoneId.of("UTC") + private val wakingDay = ZonedDateTime.of(2026, 10, 8, 0, 0, 0, 0, zone).toInstant().toEpochMilli() + private fun at(h: Int, m: Int, s: Int = 0) = wakingDay + ((h * 60L + m) * 60 + s) * 1000 + + // Records start on :23 s, as a real ring's do — so the grid is not the wall-clock minute. + private val aStart = at(0, 51, 23) + private val bStart = at(6, 56, 23) + + /** Record A, 293 minutes: light/deep/light/rem as the ring sends them, one stage per minute. */ + private val aStages = List(100) { SleepStage.LIGHT } + List(80) { SleepStage.DEEP } + + List(70) { SleepStage.LIGHT } + List(43) { SleepStage.REM } + /** Record B, 71 minutes. */ + private val bStages = List(30) { SleepStage.LIGHT } + List(20) { SleepStage.DEEP } + List(21) { SleepStage.LIGHT } + + private fun ring(start: Long, stages: List) = + buildStageBlocks("s", start, stages, recordStartAt = start) + + private val a get() = ring(aStart, aStages) + private val b get() = ring(bStart, bStages) + + private fun end(blocks: List) = blocks.maxOf { it.startAt + it.durationMinutes * 60_000L } + + /** What identity and the restate care about — ids are re-minted on every write. */ + private fun shape(blocks: List) = + blocks.sortedBy { it.startAt }.map { listOf(it.startAt, it.durationMinutes.toLong(), it.stageRaw, it.recordStartAt) } + + private fun session(blocks: List) = SleepSessionEntity( + id = "s", date = wakingDay, + startAt = blocks.minOf { it.startAt }, endAt = end(blocks), + totalMinutes = asleepMinutes(blocks), score = null, + ) + + // ── The shared function ───────────────────────────────────────────── + + @Test + fun `extending the start adds one unstaged block before the ring's own`() { + val edited = editedRecordBlocks(b, bStart, at(5, 46, 23), end(b))!! + + val added = edited.first() + assertEquals(SleepStage.ADDED.name, added.stageRaw) + assertEquals(at(5, 46, 23), added.startAt) + assertEquals(70, added.durationMinutes) + assertEquals("added minutes belong to the ring record", bStart, added.recordStartAt) + assertEquals("the ring's blocks are untouched", shape(b), shape(edited.drop(1))) + } + + @Test + fun `extending the end adds one unstaged block after the ring's own`() { + val edited = editedRecordBlocks(b, bStart, bStart, at(8, 30, 23))!! + + assertEquals(shape(b), shape(edited.dropLast(1))) + val added = edited.last() + assertEquals(SleepStage.ADDED.name, added.stageRaw) + assertEquals(end(b), added.startAt) + assertEquals(23, added.durationMinutes) + } + + @Test + fun `one edit can shorten one side and extend the other`() { + val edited = editedRecordBlocks(b, bStart, bStart + 10 * 60_000L, at(8, 17, 23))!! + + assertEquals(bStart + 10 * 60_000L, edited.first().startAt) + assertEquals(SleepStage.LIGHT.name, edited.first().stageRaw) + assertEquals(20, edited.first().durationMinutes) + assertEquals(SleepStage.ADDED.name, edited.last().stageRaw) + assertEquals(10, edited.last().durationMinutes) + assertEquals(61, measuredAsleepMinutes(edited)) + assertEquals(71, asleepMinutes(edited)) + } + + @Test + fun `shortening alone works exactly as before, adding nothing`() { + val edited = editedRecordBlocks(a, aStart, aStart + 30 * 60_000L, end(a) - 13 * 60_000L)!! + + assertEquals(0, addedMinutes(edited)) + assertEquals(293 - 30 - 13, asleepMinutes(edited)) + } + + @Test + fun `a whole-minute pick snaps inwards onto the record's grid`() { + // An edit stored before #106 holds the picker's whole minutes, not the record's :23 s. + val edited = editedRecordBlocks(b, bStart, at(5, 46), at(8, 30))!! + + assertEquals("start rounds up into the picked minute", at(5, 46, 23), edited.first().startAt) + assertEquals("end rounds down, never past the bound", at(8, 29, 23), end(edited)) + } + + @Test + fun `an edit keeping none of the ring's minutes is refused`() { + assertNull(editedRecordBlocks(b, bStart, at(5, 0, 23), bStart)) + assertNull(editedRecordBlocks(b, bStart, end(b), at(8, 30, 23))) + } + + @Test + fun `editing back inside the ring's bounds removes the added minutes`() { + val extended = editedRecordBlocks(b, bStart, at(5, 46, 23), end(b))!! + val undone = editedRecordBlocks(extended, bStart, bStart, end(b))!! + + assertEquals(shape(b), shape(undone)) + } + + // ── Re-sync: the import reproduces the edit ─────────────────────────── + + @Test + fun `re-importing the night re-creates the edit's blocks exactly`() { + val editStart = at(5, 46, 23) + val editEnd = end(b) - 5 * 60_000L + // What `SleepRecordDeletion.edit` stores: the stored record through the shared function. + val afterEdit = editedRecordBlocks(b, bStart, editStart, editEnd)!! + // What the next sync builds: the ring's raw stages, then the stored edit re-applied. + val reimported = editedRecordBlocks(ring(bStart, bStages), bStart, editStart, editEnd)!! + + assertEquals(shape(afterEdit), shape(reimported)) + // Re-applying to its own output is a no-op, so a second edit pass changes nothing either. + assertEquals(shape(afterEdit), shape(editedRecordBlocks(afterEdit, bStart, editStart, editEnd)!!)) + + // And the day's merge is idempotent: replacing the edited record's interval with the + // re-import leaves the stored night exactly as it was, record A included. + val stored = a + afterEdit + val merged = replaceOverlappingSleepBlocks( + existing = stored, + replacements = reimported, + replacementStart = reimported.minOf { it.startAt }, + replacementEnd = end(reimported), + ) + assertEquals(shape(stored), shape(merged)) + } + + @Test + fun `a stored edit gives way to a neighbour record at import`() { + // The stored edit reaches back to 5:30, but record A ends 5:44: the ring's minutes win. + val (nightStart, nightEnd) = nightWindow(wakingDay, zone) + val (start, endAt) = clampEditToNeighbours( + at(5, 30, 23), end(b), bStart, end(b), others = a, nightStart = nightStart, nightEnd = nightEnd, + ) + assertEquals(end(a), start) + assertEquals(end(b), endAt) + } + + // ── Limits and the dialog's picks ──────────────────────────────────── + + private fun limitsForB() = recordEditLimits( + run = sleepRecordRuns(a + b)[1], + previous = sleepRecordRuns(a + b)[0], + next = null, + wakingDay = wakingDay, + zone = zone, + ) + + @Test + fun `extending into the gap stops at the previous record's end`() { + val limits = limitsForB() + assertEquals(end(a), limits.earliestStart) + assertEquals(RecordEditLimit.PREVIOUS_RECORD, limits.earliestStartLimit) + + val pick = clampStartPick(at(5, 30), limits) + assertEquals(end(a), pick.at) + assertEquals(RecordEditLimit.PREVIOUS_RECORD, pick.limit) + + // The reporter's pick: 5:46, inside the gap, no message. + val ok = clampStartPick(at(5, 46), limits) + assertEquals(at(5, 46, 23), ok.at) + assertNull(ok.limit) + } + + @Test + fun `extending outward is capped at two hours past the ring's bounds`() { + val limits = limitsForB() + assertEquals(RecordEditLimit.OUTWARD_CAP, limits.latestEndLimit) + assertEquals(end(b) + 120 * 60_000L, limits.latestEnd) + + val pick = clampEndPick(at(11, 0), limits) + assertEquals(end(b) + 120 * 60_000L, pick.at) + assertEquals(RecordEditLimit.OUTWARD_CAP, pick.limit) + + val first = recordEditLimits(sleepRecordRuns(a + b)[0], null, sleepRecordRuns(a + b)[1], wakingDay, zone) + assertEquals(aStart - 120 * 60_000L, first.earliestStart) + assertEquals(RecordEditLimit.OUTWARD_CAP, first.earliestStartLimit) + assertEquals(bStart, first.latestEnd) + assertEquals(RecordEditLimit.NEXT_RECORD, first.latestEndLimit) + } + + @Test + fun `the cap counts from the ring's bounds, not a previous extension`() { + val extended = editedRecordBlocks(b, bStart, bStart, end(b) + 60 * 60_000L)!! + val limits = recordEditLimits(sleepRecordRuns(extended).single(), null, null, wakingDay, zone) + assertEquals(end(b) + 120 * 60_000L, limits.latestEnd) + } + + @Test + fun `an extension never crosses the night's 7 PM turnover`() { + // A record starting 8:10 PM the evening before: two hours back would be 6:10 PM. + val start = at(-4, 10, 23) // 20:10:23 on Oct 7 + val record = ring(start, List(60) { SleepStage.LIGHT }) + val limits = recordEditLimits(sleepRecordRuns(record).single(), null, null, wakingDay, zone) + assertEquals(RecordEditLimit.NIGHT_BOUNDARY, limits.earliestStartLimit) + assertEquals(at(-5, 0, 23), limits.earliestStart) // 19:00:23, the first grid point inside + } + + @Test + fun `picking the minute the button shows is not a change and not a message`() { + val limits = limitsForB() + val start = clampStartPick(at(6, 56), limits) + assertEquals(bStart, start.at) + assertNull(start.limit) + val endPick = clampEndPick(at(8, 7), limits) + assertEquals(end(b), endPick.at) + assertNull(endPick.limit) + } + + @Test + fun `shortening must keep a minute the ring measured`() { + val limits = limitsForB() + val pick = clampStartPick(at(9, 0), limits) + assertEquals(RecordEditLimit.RING_MINUTES, pick.limit) + assertEquals(end(b) - 60_000L, pick.at) + } + + // ── Asleep, but unstaged and unscored ──────────────────────────────── + + @Test + fun `added minutes count as asleep but not in the score or the stage shares`() { + val extended = a + editedRecordBlocks(b, bStart, end(a), end(b))!! + val ringOnly = a + b + + assertEquals(asleepMinutes(ringOnly) + 72, asleepMinutes(extended)) + assertEquals(asleepMinutes(ringOnly), measuredAsleepMinutes(extended)) + assertEquals(72, addedMinutes(extended)) + + val withAdded = SleepScore.calculate(session(extended), extended) + val without = SleepScore.calculate(session(ringOnly), ringOnly) + assertEquals(without.deepPct, withAdded.deepPct) + assertEquals(without.lightPct, withAdded.lightPct) + assertEquals(without.score, withAdded.score) + assertEquals( + sleepStageScore(100, measuredAsleepMinutes(ringOnly)), + sleepStageScore(100, measuredAsleepMinutes(extended)), + ) + } + + @Test + fun `covering the gap removes it from awake and from the hypnogram`() { + val ringOnly = a + b + assertEquals(72, awakeMinutes(ringOnly)) + + val extended = a + editedRecordBlocks(b, bStart, end(a), end(b))!! + assertEquals("still two records", 2, sleepRecordRuns(extended).size) + assertEquals(0, awakeMinutes(extended)) + assertEquals(extended.size, hypnogramBlocks(extended).size) + + // A partial cover leaves the rest of the gap as a waking. + val partial = a + editedRecordBlocks(b, bStart, at(5, 46, 23), end(b))!! + assertEquals(2, awakeMinutes(partial)) + } + + @Test + fun `closing an hour-plus gap makes the two records one night`() { + assertEquals(2, SleepSegmentation.segment(a + b).size) + val extended = a + editedRecordBlocks(b, bStart, end(a), end(b))!! + assertEquals(1, SleepSegmentation.segment(extended).size) + } + + @Test + fun `added minutes export to Health Connect as generic asleep`() { + assertEquals( + SleepSessionRecord.STAGE_TYPE_SLEEPING, + HealthConnectTypeMappings.sleepStageType(SleepStage.ADDED.name), + ) + assertTrue(SleepSessionRecord.STAGE_TYPE_SLEEPING != SleepSessionRecord.STAGE_TYPE_UNKNOWN) + } +}