Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 52 additions & 33 deletions app/src/main/java/com/pulseloop/data/SleepRecordDeletion.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<SleepStageBlockEntity>()
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<SleepStageBlockEntity>,
targets: List<SleepStageBlockEntity>,
newStartAt: Long,
newEndAt: Long,
): Pair<Long, Long> {
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<SleepStageBlockEntity>, after: Long): Long =
blocks.map { it.recordStartAt }.filter { it > after }.minOrNull() ?: Long.MAX_VALUE
Expand Down Expand Up @@ -188,14 +205,16 @@ 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(
id = row.id,
startAt = row.startAt,
endAt = row.endAt,
totalMinutes = totalMin,
score = com.pulseloop.service.sleepStageScore(deepMin, totalMin),
score = com.pulseloop.service.sleepStageScore(deepMin, measuredMin),
updatedAt = now,
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
10 changes: 9 additions & 1 deletion app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
Loading
Loading