diff --git a/AGENTS.md b/AGENTS.md index 5bb0a63..05f9e80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -500,10 +500,39 @@ shape from the same side, and two records meeting with no gap read as one run: t first. The reporter's ring closes one record and opens the next 33 seconds later, so whether the two round to the same minute is a coin toss, and losing it costs a whole session. The trade is a stale tail surviving a genuine shortening, which is minutes rather than hours and rarer than it was now -that a re-send reproduces the record's declared bounds instead of a drifted end. Carrying the -originating record's start on each block would allow both, and is the fix if the tail ever bites. - -Still open, and a fair ask: showing a split night's two records **separately** as well as merged. +that a re-send reproduces the record's declared bounds instead of a drifted end. + +**The block now carries its record's start, so the identity that was missing above exists** +(`SleepStageBlockEntity.recordStartAt`, v28, stamped by `buildStageBlocks` — see #68 below). It is +the fix this section predicted for the stale-tail trade, and `completeSessionSurvivors` can take it +whenever the tail actually bites. + +## A record boundary is stored, never inferred from a gap (issue #68) + +Showing a split night's individual records was the fair ask left open by #63. The card itself is +easy; the boundary is the whole problem, and it is the **third** time this one gap has cost a +feature. + +**This firmware reopens a record roughly a minute after closing one** — 33 seconds on the #63 +capture, `05:57 → 05:58` on the #68 reporter's Sept 9 — and blocks *within* one record are rounded +onto the same minute grid, so an ordinary rounding seam between two blocks of one record is the same +width as a genuine record boundary. That is not a threshold problem, it is the absence of the +information: `sessionId` names the *merged* row, shared by every record of the night, so from stored +data alone "next record" and "next block" are indistinguishable. `sleepRecordRuns` shipped with +`minGapMinutes = 2` and merged a genuinely split night (no card at all); 1 minute would have grown a +spurious second record on every unsplit night instead. Both answers are wrong because the question +was unanswerable. + +So the import stamps it. `buildStageBlocks` is the one place that knows — it is handed the record's +declared start — and `sleepRecordRuns` groups by `recordStartAt` rather than measuring gaps. The gap +rule survives only as the fallback for rows written before the column, and it is **all-or-nothing +per night**: one stamped record beside a legacy block would read as two records whatever the truth. +`SleepRecordRunsTest` keeps the discriminating pair — the same one-minute gap asserted as a boundary +in one test and as a seam in the other — because that pair is what no threshold can satisfy and what +a future "simplify this to a gap check" would break. + +**The general lesson:** when a merge destroys provenance, recover it at the point of the merge, not +at the point of display. Three separate fixes here tried to re-derive a record boundary downstream. ## Live workout HR on Colmi is a sport session, not an HR stream (issue #64) @@ -588,6 +617,74 @@ not a history write) but never adopts the ring's copy. That is the same ±90 s a **Known limit, worth stating when a user asks:** a reading already exported to Health Connect stays there. The export doesn't retain HC record ids, so there is nothing to delete against. +## Deleting an activity bucket needs the tombstone *and* the day's deficit (issue #70) + +`ActivityBucketDeletion` is the tombstone rule applied to intraday step blocks, and it has one more +thing to get right than `MeasurementDeletion` does. **A tombstone guards the history path; today's +number does not come from the history path.** The ring also pushes `PulseEvent.ActivityUpdate` +carrying its own *cumulative* count for the day — seconds apart, and again on every reconnect — and +`EventPersistenceSubscriber.upsertActivityDaily` ratchets the day up against it with `maxOf`. That +counter still includes the deleted block, so a restated 7,000 went back to 8,000 on the next frame: +the delete looked like it worked and then silently undid itself, which is the exact failure the +restate-without-the-ratchet rule exists to prevent. + +So the day remembers what it removed. `ActivityDailyEntity.deletedSteps` / `deletedDistanceMeters` +(v27) are subtracted from every later cumulative reading before the ratchet +(`ActivityBucketDeletion.ratchetAgainstRing`, which lives with the deletion rules rather than in the +write path — a reader looking at the ratchet has no reason to suspect a deletion changed what the +ring's counter *means*). They ride the archive for the same reason the measurement tombstones do. +This is also why the feature is only offered on today: it is the only day whose counter is still +moving. + +**Calories are dropped, not corrected.** A bucket carries steps and distance and no calorie field, +so there is nothing to subtract from the ring's own daily figure — and that figure demonstrably +counted the block the user removed. The day's `calories` is cleared instead, which makes +`DailyCalorieEstimator.deviceReportedCalories` fall through to the app's own estimate, recomputed +from the surviving buckets at deletion time rather than at the next completed sync. An estimate +consistent with the restated day beats a device figure known to be wrong. `activeMinutes` is not +touched: it is credited by `ActivityRollup` from workouts, not from step buckets. + +## A derived metric must say it is derived (issue #67) + +`DerivedStress` computes a stress figure from HRV for rings whose hardware never reports one — the +R100 answers neither the stress history query nor its monitor-state read-back (22 sends, 0 replies, +while every other state query on that ring answered), so stress there is absent rather than switched +off. The app advertised it anyway, because a capability list is a static per-family constant and not +something an individual ring confirmed, and the user got a card that could never fill. + +**The rule that matters more than the formula: it is labelled wherever it is shown.** The card reads +"Estimated from HRV — your ring doesn't measure stress", and `VitalsState.stressIsDerived` carries +the fact so no future surface can render it as a measurement by accident. A derived figure presented +as measured would be worse than the empty card it replaces — a user comparing it against the vendor +app's number is entitled to know which of the two they are looking at. + +It is scored **against that user's own recent HRV**, not a population: HRV varies several-fold +between individuals, so an absolute cutoff labels whole people permanently stressed or permanently +calm. Median and median-absolute-deviation rather than mean and standard deviation, because ring +HRV history is full of obvious outliers and one of them must not redefine the scale. It returns null +below twelve baseline readings rather than a default, for the same reason the battery estimate in +#65 refuses to answer: an unearned number on a health screen is read as a measurement. + +Derived stress only fills in where the ring returned **no** stress at all. It never overwrites or +blends with hardware readings. + +**"No stress at all" is a question about the ring, so ask it of the whole history.** Gating on the +24 h chart window instead meant an empty window decided it, and windows go empty for ordinary +reasons — the monitor switched off for a day, a ring re-paired this morning, a quiet night. A ring +that does measure stress then showed a derived number captioned "your ring doesn't measure stress", +which is a false statement about that user's hardware. The gate is +`measurementDao().hasReal(STRESS)`. + +**Labelled wherever it is shown means the chart too.** The figure reaches three surfaces — the +Vitals card, the Today tile, and the `vitals/stress` detail chart — and the detail screen builds its +own series straight from Room rather than from `VitalsState`, so it needs the derivation wired in +separately (`VitalDetailViewModel.derivedStressIn`) and carries `DetailState.isDerived` into the +same amber disclaimer card BP and glucose use. Two related traps: a derived score has no "0 means +nothing measured" sentinel, so the card's `>= 10` floor must not be applied to it (a genuinely calm +day scores below 10), and the scores skip the first readings for want of a baseline — so they +cannot be zipped positionally onto the HRV series. `DerivedStress.scored` returns each score with +the index of the reading behind it for exactly that reason. + ## Diagnostics masking keeps the routing header (issue #58) `DiagnosticsRedactor.maskPacketHex` masks a health frame's payload but keeps the leading bytes that diff --git a/app/src/main/java/com/pulseloop/data/ActivityBucketDeletion.kt b/app/src/main/java/com/pulseloop/data/ActivityBucketDeletion.kt new file mode 100644 index 0000000..1392253 --- /dev/null +++ b/app/src/main/java/com/pulseloop/data/ActivityBucketDeletion.kt @@ -0,0 +1,126 @@ +package com.pulseloop.data + +import androidx.room.withTransaction +import com.pulseloop.data.dao.MeasurementDeletionDao +import com.pulseloop.data.entity.ActivityBucketEntity +import com.pulseloop.data.entity.ActivityDailyEntity + +/** + * Deleting an individual activity bucket (issue #70). + * + * The sibling of [MeasurementDeletion], and it exists for the same reason: a ring logs activity in + * intraday blocks, so a day's step total is the sum of a couple of dozen rows rather than one + * figure, and anything that inflates one of them — the ring carried rather than worn, a rough car + * journey — is stuck in that total for good. The vitals side got a delete in #60; this is the rest + * of the same request. + * + * Three rules make it stick, and all of them live here rather than in any caller: + * + * * **Tombstone it.** `activity_buckets` is keyed by the bucket's start time and upserted, so the + * next sync of that day writes the bucket straight back. `MeasurementDeletionDao.recordActivity` + * remembers it and [com.pulseloop.service.EventPersistenceSubscriber] checks before writing. + * * **Recompute the day without the ratchet.** A day's total is the sum of its buckets, but *today* + * is ratcheted against the existing row, because the live cumulative step count legitimately + * leads the bucket history and a plain recompute would make today's count visibly drop on every + * reconnect. A deletion is the one case where the total must be allowed to fall — ratcheting + * would delete the row and leave the number it contributed sitting in the headline, which reads + * as the delete having silently failed. + * * **Remember what it took with it.** The tombstone only guards the history path. The ring also + * pushes a *live* `ActivityUpdate` carrying its own cumulative count for the day — seconds + * apart, and again on every reconnect — which still includes the deleted block, and + * `EventPersistenceSubscriber.upsertActivityDaily` ratchets the day up against it. So the + * restated total survived for about as long as it took the next frame to arrive, which is the + * same silent failure the rule above exists to prevent. `ActivityDailyEntity.deletedSteps` / + * `deletedDistanceMeters` carry the day's deficit so every later cumulative reading can be + * corrected by it. This is also why the feature is only offered on today: it is the only day a + * live counter is still moving. + * + * **Calories are dropped rather than corrected.** A bucket carries steps and distance and no + * calorie field, so there is no figure to subtract from the ring's own daily total — and that total + * demonstrably includes the block the user removed. The day's device-reported calories are cleared + * instead, which makes [com.pulseloop.service.DailyCalorieEstimator.deviceReportedCalories] fall + * through to the app's own estimate, recomputed here from the buckets that remain. An estimate + * consistent with the restated day beats a device figure known to be wrong. + */ +object ActivityBucketDeletion { + + /** + * Delete the bucket starting at [startEpoch], remember it, and restate its day. + * + * Returns true when a bucket was actually removed. + */ + suspend fun delete(db: PulseLoopDatabase, startEpoch: Long): Boolean { + val deleted = db.withTransaction { + val day = com.pulseloop.util.TimeUtil.startOfDayLocal(startEpoch) + val bucket = db.activityBucketDao().byDay(day).firstOrNull { it.startEpoch == startEpoch } + ?: return@withTransaction null + + db.measurementDeletionDao().recordActivity(listOf(startEpoch)) + db.activityBucketDao().deleteByStart(startEpoch) + restateDay(db, day, bucket) + day + } + // Outside the transaction: the estimator reads a day's worth of HR samples and buckets of + // its own, and re-deriving the calorie figure is not part of what must be atomic about the + // deletion. Re-deriving it here rather than leaving it to the next completed sync is the + // point — the headline calories would otherwise keep the deleted block's contribution for + // however long that takes. + if (deleted != null) recomputeCalories(db, deleted) + return deleted != null + } + + /** + * Rewrite [day]'s totals as the sum of the buckets it still has, and record what [removed] took + * with it. + * + * Deliberately unconditional — see the class note on the ratchet. A day whose every bucket has + * been deleted keeps its row at zero rather than being removed, so the day still reads as + * "synced, nothing recorded" rather than reverting to whatever a later partial sync writes. + */ + private suspend fun restateDay(db: PulseLoopDatabase, day: Long, removed: ActivityBucketEntity) { + val buckets = db.activityBucketDao().byDay(day) + val existing = db.activityDailyDao().byDay(day) + db.activityDailyDao().upsert( + (existing ?: ActivityDailyEntity(date = day, source = "ring_history")).copy( + steps = buckets.sumOf { it.steps }, + distanceMeters = buckets.sumOf { it.distanceMeters }, + deletedSteps = (existing?.deletedSteps ?: 0) + removed.steps, + deletedDistanceMeters = (existing?.deletedDistanceMeters ?: 0.0) + removed.distanceMeters, + // The ring's own figure counted the deleted block and can't be corrected for it — + // see the class note. Zero reads as "no device figure" to the estimator. + calories = 0.0, + updatedAt = System.currentTimeMillis(), + ) + ) + } + + private suspend fun recomputeCalories(db: PulseLoopDatabase, day: Long) { + val profile = db.userProfileDao().get() ?: return + com.pulseloop.service.DailyCalorieEstimator.recompute( + day, db, + com.pulseloop.service.DailyCalorieEstimator.Profile( + sex = profile.sex, age = profile.age, + weightKg = profile.weightKg, heightCm = profile.heightCm, + ), + ) + } + + /** The tombstone key for [startEpoch] — exposed so the write path and tests name it one way. */ + fun tombstoneId(startEpoch: Long): String = MeasurementDeletionDao.activityBucketId(startEpoch) + + /** + * What a day's stored total becomes when the ring pushes a cumulative [ringTotal] for it. + * + * Lives here rather than in [com.pulseloop.service.EventPersistenceSubscriber] because it is + * the deletion rule, not the write path's: a deleted bucket changes what the ring's own counter + * *means* for that day, and a reader who only sees the ratchet has no reason to suspect it. + * [stored] still wins where it leads, so the counter's normal behaviour — climbing ahead of the + * bucket history through the day — is unchanged on a day with nothing deleted. + */ + fun ratchetAgainstRing(stored: Int, ringTotal: Int, deletedSteps: Int): Int = + maxOf(stored, (ringTotal - deletedSteps).coerceAtLeast(0)) + + /** [ratchetAgainstRing] for distance. */ + fun ratchetAgainstRing(stored: Double, ringTotal: Double, deletedMeters: Double): Double = + maxOf(stored, (ringTotal - deletedMeters).coerceAtLeast(0.0)) +} diff --git a/app/src/main/java/com/pulseloop/data/DataArchive.kt b/app/src/main/java/com/pulseloop/data/DataArchive.kt index 8872c09..949e02b 100644 --- a/app/src/main/java/com/pulseloop/data/DataArchive.kt +++ b/app/src/main/java/com/pulseloop/data/DataArchive.kt @@ -69,6 +69,10 @@ data class PulseArchive( val distanceMeters: Double = 0.0, val activeMinutes: Int = 0, val source: String = "mock", val syncedAt: Long? = null, val createdAt: Long, val updatedAt: Long, + /** Issue #70. Rides the archive for the same reason the tombstones do: a restore wipes every + * table first, and without the deficit the ring's cumulative counter would walk the deleted + * buckets back into the day. */ + val deletedSteps: Int = 0, val deletedDistanceMeters: Double = 0.0, val estimatedActiveCalories: Double? = null, ) @@ -138,6 +142,9 @@ data class PulseArchive( @Serializable data class SleepStageBlockDTO( val id: String, val sessionId: String, val startAt: Long, val startMinute: Int, val durationMinutes: Int, val stageRaw: String, + /** Issue #68. 0 in an archive written before the column existed, which is the same "unknown" + * the migration backfills — [com.pulseloop.service.sleepRecordRuns] falls back for those. */ + val recordStartAt: Long = 0L, ) @Serializable data class CoachConversationDTO( diff --git a/app/src/main/java/com/pulseloop/data/DataArchiveService.kt b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt index 38f7b1a..4e1c778 100644 --- a/app/src/main/java/com/pulseloop/data/DataArchiveService.kt +++ b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt @@ -80,6 +80,8 @@ object DataArchiveService { syncedAt = c.longOrNull("syncedAt"), createdAt = c.long("createdAt"), updatedAt = c.long("updatedAt"), estimatedActiveCalories = c.dblOrNull("estimatedActiveCalories"), + deletedSteps = c.int_("deletedSteps"), + deletedDistanceMeters = c.dbl("deletedDistanceMeters"), ) }, activityBuckets = collect("activity_buckets") { c -> @@ -167,7 +169,7 @@ object DataArchiveService { SleepStageBlockDTO( id = c.str("id"), sessionId = c.str("sessionId"), startAt = c.long("startAt"), startMinute = c.int_("startMinute"), durationMinutes = c.int_("durationMinutes"), - stageRaw = c.str("stageRaw"), + stageRaw = c.str("stageRaw"), recordStartAt = c.long("recordStartAt"), ) }, coachConversations = collect("coach_conversations") { c -> @@ -395,6 +397,8 @@ object DataArchiveService { source = a.source, syncedAt = a.syncedAt, createdAt = a.createdAt, updatedAt = a.updatedAt, estimatedActiveCalories = a.estimatedActiveCalories, + deletedSteps = a.deletedSteps, + deletedDistanceMeters = a.deletedDistanceMeters, )) } for (b in archive.activityBuckets) { @@ -476,7 +480,7 @@ object DataArchiveService { SleepStageBlockEntity( id = block.id, sessionId = block.sessionId, startAt = block.startAt, startMinute = block.startMinute, durationMinutes = block.durationMinutes, - stageRaw = block.stageRaw, + stageRaw = block.stageRaw, recordStartAt = block.recordStartAt, ) }.groupBy { it.sessionId } for (ss in archive.sleepSessions) { diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index 98c0c06..4ab265e 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -44,7 +44,7 @@ import com.pulseloop.data.entity.* CachedFoodProductEntity::class, MeasurementDeletionEntity::class, ], - version = 25, + version = 28, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -481,6 +481,47 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** + * v25 -> v26: `device_measurement_configs.spo2IntervalMinutes` (issue #66). 0 means "follow + * the heart-rate interval", which is exactly what every existing row did implicitly, so the + * default backfills losslessly. + */ + private val MIGRATION_25_26 = object : Migration(25, 26) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `device_measurement_configs` ADD COLUMN `spo2IntervalMinutes` INTEGER NOT NULL DEFAULT 0") + } + } + + /** + * v26 -> v27: what a day's deleted activity buckets took with them (issue #70). + * + * The tombstone keeps a deleted bucket from being re-synced, but the ring's *live* + * cumulative day counter still includes it and the live path ratchets the day up against + * that counter — so today's deletion was undone seconds later. The day has to remember how + * much it removed in order to subtract it from every later cumulative reading. 0 for every + * existing row is exactly right: nothing had been deleted from them. + */ + private val MIGRATION_26_27 = object : Migration(26, 27) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `activity_daily` ADD COLUMN `deletedSteps` INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE `activity_daily` ADD COLUMN `deletedDistanceMeters` REAL NOT NULL DEFAULT 0.0") + } + } + + /** + * v27 -> v28: `sleep_stage_blocks.recordStartAt` (issue #68). + * + * Which ring record a block came from, so a split night's records can be listed without + * guessing the boundary from gaps — this ring reopens a record one minute later, the same + * width as the minute-grid rounding seam. 0 backfills every existing row as "unknown", + * which keeps those nights on the gap heuristic instead of silently calling them unsplit. + */ + private val MIGRATION_27_28 = object : Migration(27, 28) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `sleep_stage_blocks` ADD COLUMN `recordStartAt` INTEGER NOT NULL DEFAULT 0") + } + } + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") db.execSQL( @@ -572,6 +613,9 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, + MIGRATION_25_26, + MIGRATION_26_27, + MIGRATION_27_28, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index beb33e0..dbcbfde 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -200,6 +200,10 @@ interface ActivityBucketDao { @Upsert suspend fun upsert(bucket: ActivityBucketEntity) + /** One bucket, by its start time — what the Activity screen's records list deletes (issue #70). */ + @Query("DELETE FROM activity_buckets WHERE startEpoch = :startEpoch") + suspend fun deleteByStart(startEpoch: Long) + @Query("DELETE FROM activity_buckets") suspend fun clear() } @@ -685,6 +689,32 @@ interface MeasurementDeletionDao { if (regenerable.isNotEmpty()) insertAll(regenerable) } + /** + * Remember a deleted activity bucket (issue #70). + * + * `activity_buckets` is keyed by the bucket's own start time and written with `upsert` for + * exactly the reason history readings are — re-syncing a day the ring still holds must replace + * each bucket rather than accumulate it, or the daily total drifts upward on every pass. So a + * deleted bucket has the same problem a deleted reading had: the next sync writes it straight + * back. Buckets live in their own table with an epoch-millis key rather than a string id, hence + * the [ACTIVITY_ID_PREFIX] key rather than a second tombstone table — the row means the same + * thing (something regenerable the user removed) and rides the archive with the rest. + */ + suspend fun recordActivity(startEpochs: List) { + if (startEpochs.isEmpty()) return + insertAll(startEpochs.map { + MeasurementDeletionEntity( + measurementId = "$ACTIVITY_ID_PREFIX$it", + kindRaw = ACTIVITY_KIND, + timestamp = it, + ) + }) + } + + /** Asked before every bucket write, so a re-sync can't restore one the user removed. */ + @Query("SELECT EXISTS(SELECT 1 FROM measurement_deletions WHERE measurementId = :id)") + suspend fun isActivityBucketDeleted(id: String): Boolean + companion object { /** The prefix `EventPersistenceSubscriber.historyMeasurementId` builds its stable ids from. * A measurement whose id starts with this is one the ring can hand us again. */ @@ -695,5 +725,13 @@ interface MeasurementDeletionDao { /** `EventPersistenceSubscriber.SOURCE_SPOT`, duplicated to keep this DAO off the service * layer. Asserted equal in `MeasurementDeletionTest`. */ const val SPOT_SOURCE = "spot" + /** Key prefix for a deleted activity bucket (issue #70), keyed by its `startEpoch`. */ + const val ACTIVITY_ID_PREFIX = "activity:" + /** `kindRaw` for an activity tombstone. Not a [com.pulseloop.ring.MeasurementKind] — buckets + * are not measurements — so it is deliberately a name no kind can collide with. */ + const val ACTIVITY_KIND = "ACTIVITY_BUCKET" + + /** The tombstone key for the bucket starting at [startEpoch]. */ + fun activityBucketId(startEpoch: Long): String = "$ACTIVITY_ID_PREFIX$startEpoch" } } diff --git a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt index 6fc8b54..6e74fd8 100644 --- a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt @@ -87,6 +87,19 @@ data class ActivityDailyEntity( /** Ported from iOS #98: net active calories estimated from all-day HR + step buckets when the * ring does not report device-side calories. Read through [effectiveActiveCalories]. */ val estimatedActiveCalories: Double? = null, + /** + * What this day's deleted activity buckets contributed, kept so the ring's own cumulative + * counter can be corrected for them (issue #70). + * + * `activity_buckets` holds only the buckets that survive, so a sum over them restates the day + * — but the live `ActivityUpdate` total the ring pushes all day is a *cumulative* figure that + * still includes the deleted block, and the live path ratchets the day up against it. Without + * this the deletion was undone by the next frame, seconds later. + * + * See [com.pulseloop.data.ActivityBucketDeletion]. + */ + val deletedSteps: Int = 0, + val deletedDistanceMeters: Double = 0.0, ) /** @@ -140,6 +153,15 @@ data class DeviceMeasurementConfigEntity( val hrIntervalMinutes: Int = 5, val hrEnabled: Boolean = true, val spo2Enabled: Boolean = true, + /** + * All-day SpO₂ sampling interval, minutes — 0 means "follow the heart-rate interval" (issue #66). + * + * The monitor command takes the same shape for every vital (`01 {enable, interval}`), so + * an interval was always settable for blood oxygen; it simply wasn't surfaced, and inherited + * whatever heart rate was set to. On a ring running both monitors hourly that is most of the + * battery, which is why the reporter asked for it separately. + */ + val spo2IntervalMinutes: Int = 0, val stressEnabled: Boolean = true, val hrvEnabled: Boolean = true, val temperatureEnabled: Boolean = true, diff --git a/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt b/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt index 60ba427..4db21b7 100644 --- a/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt @@ -39,6 +39,20 @@ data class SleepStageBlockEntity( val startMinute: Int, val durationMinutes: Int, val stageRaw: String, // SleepStage name + /** + * The declared start of the **ring record** this block came from (issue #68). + * + * A night can arrive as several records and is merged into one session, so which record a block + * belongs to is information only the import knows — and it is not recoverable afterwards. The + * gap between two records is not a reliable substitute: this ring closes one record and opens + * the next a **single minute** later, which is the same width as the seam left by rounding each + * block onto the minute grid, so any gap threshold either merges real records or splits + * unsplit nights. + * + * `0` means "unknown" — a row written before this column existed. [SleepRecordRun] falls back + * to the gap heuristic for those rather than claiming a night was unsplit. + */ + val recordStartAt: Long = 0L, ) /** diff --git a/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt index 5ae4575..5d2a439 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt @@ -234,9 +234,10 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { } /** Send the all-day enable/disable command for every vital. The CRP protocol takes a single - * interval byte per enable, and [MeasurementSettings] carries only [MeasurementSettings.hrIntervalMinutes] - * (no per-vital cadence), so the HR interval is shared across the board. Disabled vitals are - * explicitly turned off so a reconnect can't leave a previously-enabled monitor running. */ + * interval byte per enable, and [MeasurementSettings] carries a per-vital cadence only for + * blood oxygen (issue #66) — HRV and stress still share the heart-rate interval. Disabled + * vitals are explicitly turned off so a reconnect can't leave a previously-enabled monitor + * running. */ private fun applyTimingSettings(settings: MeasurementSettings) { if (settings.hrEnabled) send(CRPProtocol.enableTimingHeartRate(settings.hrIntervalMinutes)) else send(CRPProtocol.disableTimingHeartRate()) @@ -244,7 +245,10 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { else send(CRPProtocol.disableTimingHRV()) if (settings.stressEnabled) send(CRPProtocol.enableTimingStress(settings.hrIntervalMinutes)) else send(CRPProtocol.disableTimingStress()) - if (settings.spo2Enabled) send(CRPProtocol.enableTimingSpO2(settings.hrIntervalMinutes)) + // 0 means "follow heart rate", which is what this leg did unconditionally before. + if (settings.spo2Enabled) send(CRPProtocol.enableTimingSpO2( + settings.spo2IntervalMinutes.takeIf { it > 0 } ?: settings.hrIntervalMinutes + )) else send(CRPProtocol.disableTimingSpO2()) if (settings.temperatureEnabled) send(CRPProtocol.enableTimingTemp()) else send(CRPProtocol.disableTimingTemp()) diff --git a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt index 2bb0053..08eaacb 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableCapability.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableCapability.kt @@ -79,4 +79,29 @@ enum class RingDeviceType(val displayName: String) { // RWfit family (`com.rw.revivalfit`) — one A00A GATT, two wire framings chosen post-connect. // Sold under many badges including "Colmi", which is why recognition is advertisement-only. RWFIT("RWfit ring"); + + /** + * Whether this family's wire protocol carries a blood-oxygen interval of its own (issue #66). + * + * The answer is a property of the protocol, not of [WearableCapability.MEASUREMENT_INTERVAL], + * and the two disagree — which is why this lives here rather than being inferred at the call + * site. Kept in one place because it gates both the settings control and what is sent: + * + * * **YCBT** takes `{enable, interval}` per monitor, so blood oxygen has always had its own + * interval byte — it simply inherited heart rate's ([YCBTEncoder.monitorCommands]). + * * **CRP** takes one interval byte per `enableTiming*` command, same shape + * ([CRPSyncEngine.applyTimingSettings]). + * * **Colmi** declares MEASUREMENT_INTERVAL but its blood-oxygen pref (`0x2C`) is a bare + * on/off: QRing's `BloodOxygenSettingReq` has a three-byte overload carrying an interval, + * and the vendor app never once calls it — every call site sends `getWriteInstance(boolean)`. + * Offering the control here would print "Saved & sent to ring ✓" over a frame we'd be + * inventing. + * * **LuckRing** packs all-day monitoring into a single `HEART_AUTO_SWITCH` frame with one + * interval field and an on/off flag for blood oxygen ([LuckRingEncoder.autoMonitoring]). + */ + val supportsSeparateSpo2Interval: Boolean + get() = when (this) { + YCBT, TK5, COLMI_SMART_HEALTH, CRP -> true + JRING, COLMI_R02, LUCK_RING, RWFIT -> false + } } diff --git a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt index daf81ae..cf80972 100644 --- a/app/src/main/java/com/pulseloop/ring/WearableDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/WearableDriver.kt @@ -72,6 +72,8 @@ data class MeasurementSettings( /** All-day HR sampling interval in minutes (Colmi clamps to 5..60 in 5-min steps). */ val hrIntervalMinutes: Int, val spo2Enabled: Boolean, + /** All-day SpO₂ interval in minutes; 0 follows [hrIntervalMinutes] (issue #66). */ + val spo2IntervalMinutes: Int = 0, val stressEnabled: Boolean, val hrvEnabled: Boolean, val temperatureEnabled: Boolean, diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt index fbabb62..2f8aa5b 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -83,7 +83,14 @@ class YCBTSettingsEncoder { heartMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), bloodPressureMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), temperatureMonitor(enabled = settings.temperatureEnabled, intervalMinutes = interval), - bloodOxygenMonitor(enabled = settings.spo2Enabled, intervalMinutes = interval), + // Blood oxygen carries its own interval where the user set one (issue #66); 0 means + // follow heart rate, which is what it always did. Same clamp — the firmware floors the + // interval at its own minimum whatever we ask for. + bloodOxygenMonitor( + enabled = settings.spo2Enabled, + intervalMinutes = if (settings.spo2IntervalMinutes > 0) + clampInterval(settings.spo2IntervalMinutes).toByte() else interval, + ), hrvMonitor(enabled = settings.hrvEnabled, intervalMinutes = interval), ) } diff --git a/app/src/main/java/com/pulseloop/service/BatteryProjection.kt b/app/src/main/java/com/pulseloop/service/BatteryProjection.kt new file mode 100644 index 0000000..88d35ef --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/BatteryProjection.kt @@ -0,0 +1,161 @@ +package com.pulseloop.service + +/** + * How long the ring has left, from its own battery history (issue #65). + * + * A least-squares fit over the samples, which is what the reporter asked for — but fitted over the + * **current discharge run only**, not the whole window. A 7-day window almost always contains a + * charge, and a line through "40 % falling, then 100 %, then falling again" has a slope that is an + * artefact of when the user happened to plug in: it can come out flat, or positive, and either way + * it describes nothing. The run since the last charge is the only stretch whose slope is a drain + * rate. + * + * Deliberately conservative about saying anything at all. An estimate carries more authority than + * it earns — a number on screen is read as a promise — so it is withheld unless the fit has enough + * samples, enough elapsed time and enough of a fall to mean something. + */ +object BatteryProjection { + + /** One battery reading: epoch millis and percent. */ + data class Sample(val timestampMs: Long, val percent: Double) + + /** + * @param percentPerHour how fast it is falling now (positive = draining) + * @param hoursRemaining time until empty at that rate, from the last reading + */ + data class Estimate(val percentPerHour: Double, val hoursRemaining: Double) { + /** "2d 6h" / "18h" / "45m" — the shape a runtime is actually read in. */ + val label: String + get() { + val totalMinutes = (hoursRemaining * 60).toInt().coerceAtLeast(0) + val days = totalMinutes / (24 * 60) + val hours = (totalMinutes % (24 * 60)) / 60 + val minutes = totalMinutes % 60 + return when { + days > 0 -> "${days}d ${hours}h" + hours > 0 -> "${hours}h" + else -> "${minutes}m" + } + } + } + + /** + * A rise of more than this many points is a charge, not sensor noise — the boundary of the + * current discharge run. Ring battery reporting is coarse (whole percent, and some firmware + * reports in 5 % steps), so a 1–2 point wobble at a plateau is normal and must not split a run. + */ + private const val CHARGE_RISE_POINTS = 3.0 + + /** Below this the fit is describing noise rather than a trend. */ + private const val MIN_SAMPLES = 4 + private const val MIN_SPAN_HOURS = 1.5 + private const val MIN_FALL_POINTS = 2.0 + + /** + * Below this the slope is reporting granularity, not a discharge rate. + * + * The three gates above are all about the *shape* of the run and none of them bounds how slow + * it may be: firmware that reports in 5 % steps, read over the 7 d window, satisfies every one + * of them with 80 → 75 across a week — 0.03 %/h, which projects "about 104d 4h left" and prints + * it beside "0.0 %/h". No ring lasts that long, and the number carries exactly the authority + * this module exists to withhold. 0.2 %/h is ~21 days from full: past anything a ring claims, + * so a real slow drain still gets an answer. + */ + private const val MIN_DRAIN_PERCENT_PER_HOUR = 0.2 + + /** + * The samples since the ring was last charged, oldest first. + * + * Returns the whole list when no charge is visible in it — which is the common case for a 24 h + * window and the reason the 24 h view usually has an estimate while the 7 d one may not. + */ + fun currentDischargeRun(samples: List): List { + if (samples.size < 2) return samples + val ordered = samples.sortedBy { it.timestampMs } + var runStart = 0 + for (i in 1 until ordered.size) { + if (ordered[i].percent - ordered[i - 1].percent >= CHARGE_RISE_POINTS) runStart = i + } + return ordered.subList(runStart, ordered.size) + } + + /** + * Estimate remaining runtime, or null when the data cannot support one. + * + * Null rather than a shrug of a number: "not enough data yet" is honest and a wrong estimate of + * battery life is the kind of thing someone plans a trip around. + */ + fun estimate(samples: List): Estimate? { + val run = currentDischargeRun(samples) + if (run.size < MIN_SAMPLES) return null + + val spanHours = (run.last().timestampMs - run.first().timestampMs) / 3_600_000.0 + if (spanHours < MIN_SPAN_HOURS) return null + if (run.first().percent - run.last().percent < MIN_FALL_POINTS) return null + + // Least squares against hours since the run's start, so the slope is directly %/hour. + val t0 = run.first().timestampMs + val xs = run.map { (it.timestampMs - t0) / 3_600_000.0 } + val ys = run.map { it.percent } + val n = run.size + val meanX = xs.average() + val meanY = ys.average() + var sxy = 0.0 + var sxx = 0.0 + for (i in 0 until n) { + val dx = xs[i] - meanX + sxy += dx * (ys[i] - meanY) + sxx += dx * dx + } + if (sxx <= 0.0) return null + val slope = sxy / sxx + if (slope >= 0) return null // flat or rising: no depletion to project + + val drainPerHour = -slope + if (drainPerHour < MIN_DRAIN_PERCENT_PER_HOUR) return null + val current = run.last().percent + if (current <= 0) return null + return Estimate( + percentPerHour = drainPerHour, + hoursRemaining = current / drainPerHour, + ) + } + + /** + * Gridline instants across [startMs]..[endMs], for the chart behind the line. + * + * Local midnights when the window spans more than two days, otherwise every [hourStep] hours on + * the hour — the reporter asked for a grid that says "which day" or "which hour", and a grid at + * arbitrary offsets answers neither. + */ + fun gridlines( + startMs: Long, + endMs: Long, + zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), + hourStep: Int = 6, + ): List { + if (endMs <= startMs) return emptyList() + val spanHours = (endMs - startMs) / 3_600_000.0 + val start = java.time.Instant.ofEpochMilli(startMs).atZone(zone) + val marks = mutableListOf() + if (spanHours > 48) { + var day = start.toLocalDate().plusDays(1).atStartOfDay(zone) + while (day.toInstant().toEpochMilli() <= endMs) { + marks += day.toInstant().toEpochMilli() + day = day.plusDays(1) + } + } else { + var mark = start.withMinute(0).withSecond(0).withNano(0) + // Advance to the next multiple of hourStep so the grid lands on 00:00/06:00/12:00/18:00 + // rather than wherever the window happened to open. + while (mark.hour % hourStep != 0 || mark.toInstant().toEpochMilli() <= startMs) { + mark = mark.plusHours(1) + } + while (mark.toInstant().toEpochMilli() <= endMs) { + marks += mark.toInstant().toEpochMilli() + mark = mark.plusHours(hourStep.toLong()) + } + } + return marks + } +} diff --git a/app/src/main/java/com/pulseloop/service/DerivedStress.kt b/app/src/main/java/com/pulseloop/service/DerivedStress.kt new file mode 100644 index 0000000..ceaec3b --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/DerivedStress.kt @@ -0,0 +1,101 @@ +package com.pulseloop.service + +/** + * A stress figure derived from HRV, for rings whose hardware never reports one (issue #67). + * + * The R100 answers neither the stress history query nor its monitor-state read-back — 22 sends, 0 + * replies, while every other state query on that ring answered — so on that ring stress is absent, + * not switched off. The app still advertises it, because the capability list is a static per-family + * constant rather than something an individual ring confirmed, and the user gets a card that can + * never fill. That ring does return plenty of HRV, and stress is conventionally read from HRV, so + * the number is derivable. + * + * **It is an inference, and it is labelled as one wherever it is shown.** Presenting a derived + * figure as a measurement would be worse than the empty card this replaces: the ring did not + * measure this, and a user comparing it against a friend's ring — or against the same number from + * the vendor app — is entitled to know which of the two they are looking at. + * + * ## The rule + * + * Relative to the person, not to a population. HRV varies several-fold between individuals, so an + * absolute cutoff would label whole people permanently stressed or permanently calm. The reading is + * scored against that user's own recent HRV: at their own median the score is 50, and it moves + * inversely with HRV — lower HRV than usual reads as more stress. The spread is the user's own + * too, so someone with naturally steady HRV isn't pinned to the middle of the scale. + */ +object DerivedStress { + + /** Fewer readings than this and the baseline is not that person's normal, it is a coincidence. */ + const val MIN_BASELINE_SAMPLES = 12 + + /** + * How far from the median, in units of the baseline's own spread, maps to the ends of the + * scale. Two is the conventional "unusual for you" distance and keeps ordinary days off 0/100 — + * a scale that saturates says nothing on the days it matters. + */ + private const val SPREAD_AT_FULL_SCALE = 2.0 + + data class Reading( + /** 0–100, higher meaning more stress. */ + val score: Int, + /** Always true. Present so no caller can render this without having seen the word. */ + val derived: Boolean = true, + ) + + /** + * A stress score for [hrv] against [baseline] (that user's recent HRV readings), or null when + * the baseline is too thin to mean anything. + * + * Null rather than a default: an unearned number on a health screen is read as a measurement. + */ + fun score(hrv: Double, baseline: List): Reading? { + if (hrv <= 0) return null + val usable = baseline.filter { it > 0 } + if (usable.size < MIN_BASELINE_SAMPLES) return null + + val median = median(usable) ?: return null + if (median <= 0) return null + + // Median absolute deviation: the spread measure that a few wild readings can't drag around, + // which matters because ring HRV history contains obvious outliers. + val mad = median(usable.map { kotlin.math.abs(it - median) }) ?: return null + // A perfectly flat baseline has no spread to scale against; fall back to a proportion of + // the median so the score still responds rather than snapping between 0 and 100. + val spread = if (mad > 0.0) mad else median * 0.1 + if (spread <= 0.0) return null + + val deviations = (median - hrv) / spread // positive = HRV below normal = more stress + val scaled = 50.0 + 50.0 * (deviations / SPREAD_AT_FULL_SCALE) + return Reading(score = scaled.coerceIn(0.0, 100.0).toInt()) + } + + /** + * The series a chart can show: each HRV reading scored against the baseline of the readings + * before it, so the line is what the app could have said at the time rather than hindsight. + */ + fun series(hrv: List): List = scored(hrv).map { it.second } + + /** + * [series], but each score paired with the index of the HRV reading it came from. + * + * The first readings score null (too thin a baseline), so the output is shorter than the input + * and a positional zip against the HRV series would silently shift every point. A chart needs + * the *time* of each score, and the only timestamp a derived score has is that of the HRV + * reading behind it — so the index has to travel with the score rather than be inferred. + */ + fun scored(hrv: List): List> { + val out = mutableListOf>() + for (i in hrv.indices) { + val baseline = hrv.subList(0, i) + score(hrv[i], baseline)?.let { out += i to it.score } + } + return out + } + + private fun median(values: List): Double? { + if (values.isEmpty()) return null + val sorted = values.sorted() + val mid = sorted.size / 2 + return if (sorted.size % 2 == 1) sorted[mid] else (sorted[mid - 1] + sorted[mid]) / 2.0 + } +} diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index e9ab166..d69d149 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -484,10 +484,24 @@ class EventPersistenceSubscriber( // live-activity decode before this fix — overwrite it so the day self-heals instead // of staying stuck at a garbage number until midnight. val stale = existing.steps > 200_000 + // The ring's counter is cumulative for the day, so it still contains any bucket the + // user deleted (issue #70) — ratcheting against it raw restored the deleted block + // within seconds, and again on every reconnect. See [ActivityBucketDeletion]. + val deletion = com.pulseloop.data.ActivityBucketDeletion + val hasDeletion = existing.deletedSteps > 0 || existing.deletedDistanceMeters > 0.0 db.activityDailyDao().upsert(existing.copy( - steps = if (stale) steps else maxOf(existing.steps, steps), - calories = if (stale) calories else maxOf(existing.calories, calories), - distanceMeters = if (stale) distanceM else maxOf(existing.distanceMeters, distanceM), + steps = if (stale) steps + else deletion.ratchetAgainstRing(existing.steps, steps, existing.deletedSteps), + // A bucket carries no calorie field, so there is nothing to subtract from the + // ring's own figure — the deletion drops it and the app's estimate takes over + // (see [com.pulseloop.data.ActivityBucketDeletion]). Ratcheting here would put it + // straight back. + calories = if (stale) calories + else if (hasDeletion) existing.calories + else maxOf(existing.calories, calories), + distanceMeters = if (stale) distanceM else deletion.ratchetAgainstRing( + existing.distanceMeters, distanceM, existing.deletedDistanceMeters, + ), updatedAt = System.currentTimeMillis(), )) } else { @@ -511,6 +525,14 @@ class EventPersistenceSubscriber( private suspend fun applyActivityBucketAtomic(ts: Long, steps: Int, distanceM: Double) { val dayStart = com.pulseloop.util.TimeUtil.startOfDayLocal(ts) + // A bucket the user deleted (issue #70). Buckets upsert by start time so the day's total is + // the sum of distinct buckets rather than an accumulation — which is also what would write + // a deleted one straight back on the next sync of that day, exactly as it would have for a + // deleted reading in #60. + if (db.measurementDeletionDao().isActivityBucketDeleted( + com.pulseloop.data.ActivityBucketDeletion.tombstoneId(ts) + ) + ) return db.activityBucketDao().upsert(ActivityBucketEntity( startEpoch = ts, date = dayStart, @@ -692,6 +714,13 @@ class EventPersistenceSubscriber( /** * 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): List { if (stages.isEmpty()) return emptyList() @@ -712,6 +741,7 @@ class EventPersistenceSubscriber( startMinute = blockMinute, durationMinutes = duration, stageRaw = currentStage.name, + recordStartAt = startTs, )) currentStage = stage blockStart = startTs + i * 60_000L @@ -726,6 +756,7 @@ class EventPersistenceSubscriber( startMinute = blockMinute, durationMinutes = duration, stageRaw = currentStage.name, + recordStartAt = startTs, )) return blocks } diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 3687d93..e662e1f 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -145,6 +145,30 @@ class RingSyncCoordinator( } /** The SpO₂ leg's ceiling for the ring that is actually connected (issue #59 RC-2). */ private val spo2MeasureSeconds: Long get() = (engine?.spotSpo2Seconds ?: SPO2_MEASURE_SECONDS).toLong() + + /** + * The countdown for one leg run on its own (issue #66). + * + * The two legs have almost nothing in common on a YCBT ring — heart rate runs to 45 s and the + * ring self-terminates around 35, blood oxygen runs to 75 s and produces its first sample at + * t+13 — so a single number for both is a promise the app can't keep either way. Each is this + * ring's own ceiling plus the same one-off start-up allowance [spotMeasureSeconds] adds. + */ + val heartRateMeasureSeconds: Int get() = hrMeasureSeconds.toInt() + 3 + val spo2OnlyMeasureSeconds: Int get() = spo2MeasureSeconds.toInt() + 3 + + /** + * True while any spot leg holds the optical sensor. + * + * The legs were only ever run in sequence by [measureSpot], so "am I already measuring?" was a + * per-leg question. With separate controls a user can reach for the second leg while the first + * is still running, and the two cannot share the sensor — the ring serves one at a time and the + * live-sample gate is one switch per kind. Each leg still guards its own state; this is what + * the UI disables against so the answer is visible rather than a silent no-op. + */ + val spotMeasureInProgress: Boolean + get() = hrState == MeasureState.MEASURING || spo2State == MeasureState.MEASURING || + hrvState == MeasureState.MEASURING || bloodPressureState == MeasureState.MEASURING private val combinedMeasureSeconds = COMBINED_MEASURE_SECONDS.toLong() companion object { @@ -475,8 +499,76 @@ class RingSyncCoordinator( if (caps.contains(WearableCapability.MANUAL_HRV)) measureHRV() } + /** + * One leg on its own (issue #66). + * + * The Vitals screen ran every capability-gated leg in sequence behind a single button, so a + * user who wanted a heart rate waited out a minute of blood-oxygen sampling they never asked + * for — on one tester's ring the sweep started at 123 s and heart rate landed with 85 s still + * on the clock. These are what the separate controls call. Refused while another leg holds the + * sensor, because the ring serves one at a time. + */ + suspend fun measureHeartRateOnly(): Int? { + if (spotMeasureInProgress) return null + if (!canMeasureHeartRate) return null + // A refusal clears the leg's state instead of just returning. The states persist as + // DONE/FAILED after a run, so leaving one behind had the caller report the *previous* + // run's verdict as this one's — [measureSpot] refuses the same way when disconnected. + if (!isConnected) { hrState = MeasureState.IDLE; return null } + return measureHR() + } + + suspend fun measureSpO2Only(): Int? { + if (spotMeasureInProgress) return null + if (!canMeasureSpO2) return null + if (!isConnected) { spo2State = MeasureState.IDLE; return null } + return measureSpO2() + } + + /** + * The legs the separate HR and SpO₂ controls don't cover, run in sequence (issue #66). + * + * [measureSpot] is the only caller of [measureBloodPressure] and [measureHRV] in the app, so a + * ring that advertises both manual HR and manual SpO₂ — and therefore gets the separate + * controls instead of the single "Measure" button — had no way left to run those two at all. + * A ring advertising neither capability gets no control and this is never called. + */ + suspend fun measureRemainingLegs() { + if (spotMeasureInProgress) return + if (!isConnected) { + // Same refusal rule as the two legs above, applied to both states this run would set. + bloodPressureState = MeasureState.IDLE + hrvState = MeasureState.IDLE + return + } + if (canMeasureBloodPressure) measureBloodPressure() + if (canMeasureHrv) measureHRV() + } + + /** Whether each separate control should be offered at all, for the connected ring. */ + val canMeasureHeartRate: Boolean + get() = client.state.value.activeCapabilities.contains(WearableCapability.MANUAL_HEART_RATE) + val canMeasureSpO2: Boolean + get() = client.state.value.activeCapabilities.contains(WearableCapability.MANUAL_SPO2) + val canMeasureBloodPressure: Boolean + get() = client.state.value.activeCapabilities.contains(WearableCapability.MANUAL_BLOOD_PRESSURE) + val canMeasureHrv: Boolean + get() = client.state.value.activeCapabilities.contains(WearableCapability.MANUAL_HRV) + + /** Countdown for [measureRemainingLegs] — the sum of the legs it will actually run. */ + val remainingLegsMeasureSeconds: Int + get() { + var total = 3 + if (canMeasureBloodPressure) total += BP_MEASURE_SECONDS + if (canMeasureHrv) total += HRV_MEASURE_SECONDS + return total + } + suspend fun measureHR(): Int? { if (hrState == MeasureState.MEASURING) return null + // Clear last run's verdict before any path that can return without reaching MEASURING (the + // workout refusal below), so the caller never reads an old DONE/FAILED as this run's. + hrState = MeasureState.IDLE if (!isConnected) { hrState = MeasureState.FAILED; return null } // A workout owns the bpm stream for its whole duration, and this leg cannot share it: the // live-sample gate is one switch per kind, so whichever of the two closed it decides @@ -891,6 +983,7 @@ internal suspend fun loadPersistedMeasurementSettings(db: PulseLoopDatabase): Me hrEnabled = config.hrEnabled, hrIntervalMinutes = config.hrIntervalMinutes, spo2Enabled = config.spo2Enabled, + spo2IntervalMinutes = config.spo2IntervalMinutes, stressEnabled = config.stressEnabled, hrvEnabled = config.hrvEnabled, temperatureEnabled = config.temperatureEnabled, diff --git a/app/src/main/java/com/pulseloop/service/SleepInsights.kt b/app/src/main/java/com/pulseloop/service/SleepInsights.kt index 2a02349..799d4e7 100644 --- a/app/src/main/java/com/pulseloop/service/SleepInsights.kt +++ b/app/src/main/java/com/pulseloop/service/SleepInsights.kt @@ -152,6 +152,78 @@ fun asleepMinutes(blocks: List): Int = val SleepSessionEntity.spanMinutes: Int get() = ((endAt - startAt) / 60_000L).toInt().coerceAtLeast(0) +/** + * One ring sleep record inside a stored session (issue #68). + * + * A ring closes a session when the wearer gets up and opens a new one when they settle, so one + * night can arrive as two or three records minutes apart. The app merges those into a single stored + * session — that is the night, and the merge is what #63 fixed — but the individual records are real + * information the merge then hides, and the vendor app shows them. This is that, recovered from + * stored data rather than re-parsed: since #63 each record's blocks are placed against its own + * declared bounds, so a record boundary is a stretch of the session's timeline that no block claims. + */ +data class SleepRecordRun( + val startAt: Long, + val endAt: Long, + val blocks: List, +) { + val asleepMinutes: Int get() = asleepMinutes(blocks) + val spanMinutes: Int get() = ((endAt - startAt) / 60_000L).toInt().coerceAtLeast(0) +} + +/** + * Split [blocks] into the ring records they came from. + * + * **The boundary is read, not inferred.** Each block carries the declared start of the record it + * arrived in ([SleepStageBlockEntity.recordStartAt], stamped by `buildStageBlocks`), so records are + * grouped by that. The gap between two records is *not* a usable substitute: this ring closes one + * record and opens the next a single minute later (05:57 → 05:58 on the reporter's Sept 9), which + * is exactly the width of the seam left by rounding each block onto the minute grid *within* one + * record. No threshold separates those two cases — 2 minutes merged a genuinely split night, and + * 1 minute would grow a spurious second record on every unsplit one. The same one-minute boundary + * broke the #63 merge itself, so it is a real property of this firmware rather than one night. + * + * [minGapMinutes] is the fallback for rows written before that column existed, where the gap is + * all there is. Those nights keep the old behaviour rather than being claimed as unsplit. + * + * Returns one run for an unsplit night, which is the common case and the reason a caller can show + * this unconditionally. + */ +fun sleepRecordRuns( + blocks: List, + minGapMinutes: Int = 2, +): List { + if (blocks.isEmpty()) return emptyList() + val ordered = blocks.sortedBy { it.startAt } + + fun endOf(b: SleepStageBlockEntity) = b.startAt + b.durationMinutes * 60_000L + fun runOf(group: List) = + SleepRecordRun(group.first().startAt, group.maxOf { endOf(it) }, group) + + // Every block knows its record: group by it and we are done. All-or-nothing, because a night + // that is part migrated and part not has no consistent boundary to read — one stamped record + // plus a legacy block would look like a two-record night whatever the truth. + if (ordered.all { it.recordStartAt > 0L }) { + return ordered.groupBy { it.recordStartAt } + .entries.sortedBy { it.key } + .map { (_, group) -> runOf(group.sortedBy { it.startAt }) } + } + + val runs = mutableListOf() + var current = mutableListOf(ordered.first()) + for (block in ordered.drop(1)) { + val gapMs = block.startAt - current.maxOf { endOf(it) } + if (gapMs >= minGapMinutes * 60_000L) { + runs += runOf(current.toList()) + current = mutableListOf(block) + } else { + current += block + } + } + runs += runOf(current.toList()) + return runs +} + object SleepFormat { fun duration(minutes: Int?): String { if (minutes == null || minutes < 0) return "—" diff --git a/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt b/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt index 768f4eb..bf81b64 100644 --- a/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt +++ b/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt @@ -55,6 +55,12 @@ fun ZoneLineChart( * turns sparse multi-day series (daily HRV/temp) into the scatter of isolated dots. */ maxGapMs: Long = 90 * 60_000L, + /** + * Instants to rule a faint vertical line at — day or hour boundaries, so a reader can tell + * *when* a trend happened rather than only its shape (issue #65). Empty for every other chart, + * which is why this is opt-in rather than a default. + */ + verticalGridlines: List = emptyList(), ) { if (samples.size < 2) return val minTs = samples.first().timestampMs @@ -87,6 +93,16 @@ fun ZoneLineChart( size = Size(size.width, (bottom - top).coerceAtLeast(0f)), ) } + // Time grid, behind the data and quiet enough not to compete with it. + verticalGridlines.forEach { at -> + if (at in minTs..maxTs) { + drawLine( + color = PulseColors.textMuted.copy(alpha = 0.22f), + start = Offset(x(at), 0f), end = Offset(x(at), size.height), + strokeWidth = 1.dp.toPx(), + ) + } + } // Dashed rules. dashedRules.forEach { rule -> val ry = y(rule) diff --git a/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt index 9e04da6..a729ec6 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt @@ -14,10 +14,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Map import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Switch @@ -217,6 +221,17 @@ fun ActivityScreen( } item { WeeklyGoalCard(state, days, onTap = { goalsOpen = true }) } + // The blocks behind today's total, each removable (issue #70). Under the summary rather + // than beside it: it answers "why is that number wrong", which is a question you only ask + // after reading the number. + item { + ActivityRecordsCard( + viewModel = viewModel, + units = units, + dayStart = com.pulseloop.util.TimeUtil.startOfTodayLocal(), + dayLabel = "today", + ) + } item { Spacer(Modifier.height(64.dp)) } } @@ -648,3 +663,146 @@ private fun GoalSlider(label: String, valueText: String, value: Float, range: Cl Slider(value = value, onValueChange = onChange, valueRange = range) } } + +/** + * The individual activity records behind a day's total, each deletable (issue #70). + * + * The counterpart to the vitals READINGS list from #60, and asked for by the same reporter for the + * same reason: a ring logs activity in intraday blocks, so a day's steps are a couple of dozen rows + * rather than one figure, and a single block inflated by carrying the ring rather than wearing it + * is otherwise stuck in the total for good. Deletion only, like #60 — a recorded value can be + * removed, never edited into a different one. + * + * Collapsed by default and paged, because a day is a couple of dozen rows and a week is not. + */ +@Composable +private fun ActivityRecordsCard( + viewModel: ActivityViewModel?, + units: UnitSystem, + dayStart: Long, + dayLabel: String, +) { + val scope = rememberCoroutineScope() + var expanded by remember { mutableStateOf(false) } + var buckets by remember(dayStart) { mutableStateOf?>(null) } + var pending by remember { mutableStateOf(null) } + var visibleCount by remember(dayStart) { mutableStateOf(ACTIVITY_RECORDS_PAGE_SIZE) } + val shape = RoundedCornerShape(20.dp) + + // Loaded only when opened: the list is a query per day and most visits never expand it. + LaunchedEffect(expanded, dayStart) { + if (expanded && buckets == null) buckets = viewModel?.bucketsForDay(dayStart) ?: emptyList() + } + + fun timeOf(startEpoch: Long): String = + DateTimeFormatter.ofPattern("h:mm a") + .format(Instant.ofEpochMilli(startEpoch).atZone(ZoneId.systemDefault())) + + Column( + Modifier.fillMaxWidth().clip(shape).background(PulseColors.card).border(1.dp, PulseColors.borderSubtle, shape), + ) { + Row( + Modifier.fillMaxWidth().clickable { expanded = !expanded }.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "RECORDS", + fontSize = 11.sp, fontWeight = FontWeight.SemiBold, + letterSpacing = 1.sp, color = PulseColors.textMuted, + ) + Spacer(Modifier.weight(1f)) + buckets?.let { + Text(it.size.toString(), fontSize = 12.sp, color = PulseColors.textMuted, modifier = Modifier.padding(end = 6.dp)) + } + Icon( + if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "Hide activity records" else "Show activity records", + tint = PulseColors.textMuted, + modifier = Modifier.size(20.dp), + ) + } + + if (expanded) { + val rows = buckets + when { + rows == null -> Text( + "Loading…", + fontSize = 13.sp, color = PulseColors.textMuted, + modifier = Modifier.padding(start = 16.dp, bottom = 12.dp), + ) + rows.isEmpty() -> Text( + "No activity records synced for $dayLabel yet.", + fontSize = 13.sp, color = PulseColors.textMuted, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) + else -> { + // Not a nested LazyColumn — this card already sits inside one. + rows.take(visibleCount).forEach { bucket -> + Row( + Modifier.fillMaxWidth().padding(start = 16.dp, end = 6.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + timeOf(bucket.startEpoch), + fontSize = 13.sp, color = PulseColors.textSecondary, + modifier = Modifier.weight(1f), + ) + Text( + "${Formats.count(bucket.steps)} steps", + fontSize = 13.sp, fontWeight = FontWeight.Medium, color = PulseColors.textPrimary, + ) + if (bucket.distanceMeters > 0) { + Text( + " · %.2f %s".format(UnitConverter.distance(bucket.distanceMeters, units), UnitConverter.distanceUnit(units)), + fontSize = 11.sp, color = PulseColors.textMuted, + ) + } + IconButton(onClick = { pending = bucket }, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Filled.DeleteOutline, + contentDescription = "Delete this activity record", + tint = PulseColors.textMuted, + modifier = Modifier.size(18.dp), + ) + } + } + } + if (rows.size > visibleCount) { + TextButton( + onClick = { visibleCount += ACTIVITY_RECORDS_PAGE_SIZE }, + modifier = Modifier.padding(start = 8.dp), + ) { + Text("Show ${minOf(ACTIVITY_RECORDS_PAGE_SIZE, rows.size - visibleCount)} more", fontSize = 13.sp) + } + } + Spacer(Modifier.height(8.dp)) + } + } + } + } + + pending?.let { bucket -> + AlertDialog( + onDismissRequest = { pending = null }, + title = { Text("Delete this activity record?") }, + text = { + Text( + "${Formats.count(bucket.steps)} steps at ${timeOf(bucket.startEpoch)}." + + "\n\nThe day's total drops by that much. This can't be undone, and the record " + + "stays deleted the next time this day syncs.", + ) + }, + confirmButton = { + TextButton(onClick = { + val target = bucket + pending = null + scope.launch { buckets = viewModel?.deleteBucket(target.startEpoch) ?: buckets } + }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = { pending = null }) { Text("Cancel") } }, + ) + } +} + +/** How many activity records the list shows before asking (issue #70). */ +private const val ACTIVITY_RECORDS_PAGE_SIZE = 50 diff --git a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt index 2f60aaf..f930a0c 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt @@ -61,6 +61,12 @@ fun VitalsScreen( val scope = rememberCoroutineScope() var measuring by remember { mutableStateOf(false) } var remaining by remember { mutableStateOf(0) } + /** Which control is counting down, so only that button shows the timer (issue #66). */ + var measuringLabel by remember { mutableStateOf("") } + /** The running leg's own window, so the progress bar measures the run and not the full sweep. */ + var measureTotal by remember { mutableStateOf(0) } + /** What the running leg is actually measuring — "heart rate & SpO₂" is wrong for a single leg. */ + var measureCaption by remember { mutableStateOf("") } // Set when a spot measurement finishes with every leg FAILED — iOS #66's "an honest retry": // the refusal gate keeps bad values off screen, and this surfaces the failure with iOS's // per-kind copy instead of silently re-enabling the button (second-pass finding #30). @@ -209,8 +215,15 @@ fun VitalsScreen( } DashboardCard.STRESS -> VitalGaugeCardItem( card = cards.getValue(MetricKind.STRESS), - hasReading = state.stressSamples.isNotEmpty() && (state.latestStress?.toInt() ?: 0) >= 10, + // The `>= 10` floor reads a ring's 0 as "nothing measured". A derived score has no + // such sentinel — its whole 0–100 range is meaningful, and a genuinely calm day + // scores below 10 — so the floor would hide exactly the reading it was asked for. + hasReading = state.stressSamples.isNotEmpty() && + (state.stressIsDerived || (state.latestStress?.toInt() ?: 0) >= 10), emptyText = "No stress data yet — take a measurement.", + // Issue #67: say so on the card itself. A derived figure presented as a measurement + // would be worse than the empty card it replaces. + footnote = if (state.stressIsDerived) "Estimated from HRV — your ring doesn't measure stress" else null, onTap = { navController?.navigate("vitals/stress") }, ) DashboardCard.FATIGUE -> VitalGaugeCardItem( @@ -263,32 +276,136 @@ fun VitalsScreen( // Measure button: combined (0x23) for 56ff/Jring, or sequential live // HR + SpO₂ (0x69) for Colmi. Hidden for rings that support neither. if (coordinator != null && (combinedMode || spotMode)) { + val failedState = com.pulseloop.service.RingSyncCoordinator.MeasureState.FAILED + + /** + * Run one measurement, whatever its shape, with its own countdown. + * + * [failed] is asked only about the legs *this* run started. The leg states + * persist as DONE/FAILED after a run, so a blanket "did HR or SpO₂ fail?" read + * a previous run's verdict — a failed SpO₂ attempt made the next successful + * heart-rate reading report failure. + */ + fun runMeasurement( + seconds: Int, + label: String, + caption: String, + failed: () -> Boolean, + leg: suspend () -> Unit, + ) { + measuring = true + measuringLabel = label + measureFailed = false + measureNotWornHint = false + remaining = seconds + measureTotal = seconds + measureCaption = caption + scope.launch { + val ticker = launch { + while (remaining > 0) { kotlinx.coroutines.delay(1000); remaining-- } + } + try { + leg() + } finally { + ticker.cancel() + remaining = 0 + measuring = false + if (failed()) { + measureFailed = true + measureNotWornHint = coordinator.measureNotWorn + } + viewModel?.refreshNow() // show the new reading immediately + } + } + } + + // Issue #66: heart rate and blood oxygen are separate measurements on a ring + // that runs them sequentially — 45 s against 75 s, with completely different + // sample spacing — so a user after one should not be held for the other. The + // combined flow keeps its single button: there it really is one packet. + val separateLegs = !combinedMode && + coordinator.canMeasureHeartRate && coordinator.canMeasureSpO2 + // The legs the two separate controls don't cover. `measureSpot()` was the only + // caller of the BP and HRV legs in the app, so replacing it with HR + SpO₂ + // buttons took manual BP and HRV away from a ring that advertises them — they + // get their own control rather than disappearing. + val remainingLegsLabel = when { + coordinator.canMeasureBloodPressure && coordinator.canMeasureHrv -> "BP & HRV" + coordinator.canMeasureBloodPressure -> "BP" + coordinator.canMeasureHrv -> "HRV" + else -> null + } + if (separateLegs) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = !measuring, + onClick = { + runMeasurement( + coordinator.heartRateMeasureSeconds, "HR", + caption = "Keep still — measuring heart rate…", + failed = { coordinator.hrState == failedState }, + ) { coordinator.measureHeartRateOnly() } + }, + ) { + Text( + if (measuring && measuringLabel == "HR") "HR ${remaining}s" else "HR", + color = androidx.compose.ui.graphics.Color.White, + ) + } + Button( + enabled = !measuring, + onClick = { + runMeasurement( + coordinator.spo2OnlyMeasureSeconds, "SpO₂", + caption = "Keep still — measuring blood oxygen…", + failed = { coordinator.spo2State == failedState }, + ) { coordinator.measureSpO2Only() } + }, + ) { + Text( + if (measuring && measuringLabel == "SpO₂") "SpO₂ ${remaining}s" else "SpO₂", + color = androidx.compose.ui.graphics.Color.White, + ) + } + if (remainingLegsLabel != null) { + Button( + enabled = !measuring, + onClick = { + runMeasurement( + coordinator.remainingLegsMeasureSeconds, remainingLegsLabel, + caption = "Keep still — measuring $remainingLegsLabel…", + failed = { + (coordinator.canMeasureBloodPressure && + coordinator.bloodPressureState == failedState) || + (coordinator.canMeasureHrv && coordinator.hrvState == failedState) + }, + ) { coordinator.measureRemainingLegs() } + }, + ) { + Text( + if (measuring && measuringLabel == remainingLegsLabel) + "$remainingLegsLabel ${remaining}s" else remainingLegsLabel, + color = androidx.compose.ui.graphics.Color.White, + ) + } + } + } + } else { Button( enabled = !measuring, onClick = { - measuring = true - measureFailed = false - measureNotWornHint = false - remaining = measureSeconds - scope.launch { - val ticker = launch { - while (remaining > 0) { kotlinx.coroutines.delay(1000); remaining-- } - } - try { - if (combinedMode) coordinator.measureCombined() else coordinator.measureSpot() - } finally { - ticker.cancel() - remaining = 0 - measuring = false - if (!combinedMode && - (coordinator.hrState == com.pulseloop.service.RingSyncCoordinator.MeasureState.FAILED || - coordinator.spo2State == com.pulseloop.service.RingSyncCoordinator.MeasureState.FAILED) - ) { - measureFailed = true - measureNotWornHint = coordinator.measureNotWorn - } - viewModel?.refreshNow() // show the new reading immediately - } + runMeasurement( + measureSeconds, "all", + caption = if (combinedMode) + "Keep still — measuring blood pressure, SpO₂, stress, fatigue & blood sugar…" + else + "Keep still — measuring heart rate & SpO₂…", + failed = { + !combinedMode && + (coordinator.hrState == failedState || coordinator.spo2State == failedState) + }, + ) { + if (combinedMode) coordinator.measureCombined() else coordinator.measureSpot() } }, ) { @@ -297,19 +414,23 @@ fun VitalsScreen( color = androidx.compose.ui.graphics.Color.White, ) } + } } } if (measuring) { Spacer(Modifier.height(8.dp)) LinearProgressIndicator( - progress = { ((measureSeconds - remaining).toFloat() / measureSeconds).coerceIn(0f, 1f) }, + // Against the *running* leg's own window, not the whole sweep's: an HR-only run + // counts down from 48 s while the sweep bound is 123 s, so the shared divisor + // started the bar 61 % full and it never described the run. + progress = { + val total = measureTotal.coerceAtLeast(1) + ((total - remaining).toFloat() / total).coerceIn(0f, 1f) + }, modifier = Modifier.fillMaxWidth(), ) Text( - if (combinedMode) - "Keep still — measuring blood pressure, SpO₂, stress, fatigue & blood sugar…" - else - "Keep still — measuring heart rate & SpO₂…", + measureCaption, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -375,9 +496,16 @@ private fun VitalGaugeCardItem( hasReading: Boolean, emptyText: String, measuring: Boolean = false, + /** Replaces the usual footer when the value isn't a measurement — see issue #67. */ + footnote: String? = null, onTap: () -> Unit, ) { - VitalCard(state = card, showsValueRow = false, footerOverride = gaugeFooter(card), onTap = onTap) { + VitalCard( + state = card, + showsValueRow = false, + footerOverride = footnote ?: gaugeFooter(card), + onTap = onTap, + ) { if (hasReading) { Box( Modifier.fillMaxWidth().padding(vertical = 4.dp), @@ -696,8 +824,10 @@ fun VitalDetailScreen( } } - // 7. Estimated-metric disclaimer (BP + glucose only, iOS warning card). - metricDisclaimer(metric)?.let { disclaimer -> + // 7. Estimated-metric disclaimer (BP + glucose, iOS warning card; plus derived + // stress, issue #67 — the card that opened this chart labels its number, and this + // chart shows the same number, so it says the same thing here). + metricDisclaimer(metric, state.isDerived)?.let { disclaimer -> item { val cardShape = RoundedCornerShape(20.dp) Row( @@ -977,12 +1107,22 @@ private fun zoneRangeText( } } -/** Warning-card copy for estimated metrics (iOS `disclaimerText`); null hides the card. */ -private fun metricDisclaimer(metric: String): String? = when (metric) { - "glucose" -> +/** + * Warning-card copy for estimated metrics (iOS `disclaimerText`); null hides the card. + * + * [isDerived] is the issue #67 case: a stress chart computed from HRV because the ring reports no + * stress at all. It gets the card for the same reason BP and glucose do — the number on screen is + * an inference, and the user is entitled to know that without leaving the chart. + */ +private fun metricDisclaimer(metric: String, isDerived: Boolean): String? = when { + metric == "stress" && isDerived -> + "Estimated from HRV — your ring doesn't measure stress. Scored against your own recent " + + "HRV readings, so it says how this reading compares with your normal, not how you compare " + + "with anyone else." + metric == "glucose" -> "Estimated wellness metric — not for dosing or diabetes decisions. No smart ring or watch is " + "FDA-authorized to measure or estimate glucose on its own." - "bp" -> + metric == "bp" -> "Ring blood pressure is an estimate. Calibrate against a validated cuff in Settings → Calibration, " + "and talk to a clinician about persistent high or low readings." else -> null diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index b1400a6..bd57254 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -1449,12 +1449,22 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U val capabilities = device?.capabilities.orEmpty() val minimumInterval = if (device?.deviceType == RingDeviceType.YCBT) 30 else 5 val intervalSteps = ((60 - minimumInterval) / 5 - 1).coerceAtLeast(0) + // Only offered where the ring's protocol actually carries a separate blood-oxygen interval — + // elsewhere the slider moved, the value saved, and "Saved & sent to ring ✓" appeared over a + // setting nothing on the wire could express (issue #66). + val supportsSpo2Interval = device?.deviceType?.supportsSeparateSpo2Interval == true + // The SpO₂ slider runs five minutes lower than the HR one (its floor is "follow heart rate"), + // so it needs its own step count: reusing the HR one put ticks 5.45 min apart, none of which + // the 5-minute snap could land on. + val spo2IntervalSteps = ((60 - (minimumInterval - 5)) / 5 - 1).coerceAtLeast(0) val supportsStressSetting = WearableCapability.STRESS in capabilities && device?.deviceType != RingDeviceType.YCBT var cfgHrEnabled by remember { mutableStateOf(true) } var cfgHrInterval by remember { mutableStateOf(5) } var cfgSpo2 by remember { mutableStateOf(true) } + /** 0 = follow the heart-rate interval, which is what it always did (issue #66). */ + var cfgSpo2Interval by remember { mutableStateOf(0) } var cfgStress by remember { mutableStateOf(true) } var cfgHrv by remember { mutableStateOf(true) } var cfgTemp by remember { mutableStateOf(true) } @@ -1468,6 +1478,9 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U cfgHrEnabled = config?.hrEnabled ?: true cfgHrInterval = (config?.hrIntervalMinutes ?: minimumInterval).coerceIn(minimumInterval, 60) cfgSpo2 = (config?.spo2Enabled ?: true) && WearableCapability.SPO2 in capabilities + cfgSpo2Interval = (config?.spo2IntervalMinutes ?: 0).let { + if (it <= 0) 0 else it.coerceIn(minimumInterval, 60) + } cfgStress = (config?.stressEnabled ?: true) && supportsStressSetting cfgHrv = (config?.hrvEnabled ?: true) && WearableCapability.HRV in capabilities cfgTemp = (config?.temperatureEnabled ?: true) && WearableCapability.TEMPERATURE in capabilities @@ -1519,6 +1532,30 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U HorizontalDivider(Modifier.padding(vertical = 8.dp)) if (WearableCapability.SPO2 in capabilities) { VitalToggle("Blood oxygen (SpO₂)", cfgSpo2) { cfgSpo2 = it } + // The monitor command takes the same shape for every vital + // (`01 {enable, interval}`), so blood oxygen always had an interval — + // it just wasn't surfaced and silently followed heart rate. On a ring + // running both monitors hourly that is most of the battery (issue #66). + if (cfgSpo2 && supportsSpo2Interval) { + Text( + if (cfgSpo2Interval <= 0) "Same as heart rate" else "Every $cfgSpo2Interval min", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + enabled = cfgLoaded, + // The bottom of the track is "follow heart rate" rather than a + // shorter interval than the ring will honour. + value = (if (cfgSpo2Interval <= 0) minimumInterval - 5 else cfgSpo2Interval).toFloat(), + onValueChange = { + val stepped = (it / 5).toInt() * 5 + cfgSpo2Interval = if (stepped < minimumInterval) 0 else stepped.coerceAtMost(60) + cfgSavedMsg = null + }, + valueRange = (minimumInterval - 5).toFloat()..60f, + steps = spo2IntervalSteps, + ) + } } if (supportsStressSetting) { VitalToggle("Stress", cfgStress) { cfgStress = it } @@ -1541,6 +1578,8 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U hrIntervalMinutes = cfgHrInterval.coerceIn(minimumInterval, 60), hrEnabled = cfgHrEnabled, spo2Enabled = cfgSpo2 && WearableCapability.SPO2 in capabilities, + spo2IntervalMinutes = if (!supportsSpo2Interval || cfgSpo2Interval <= 0) 0 + else cfgSpo2Interval.coerceIn(minimumInterval, 60), stressEnabled = cfgStress && supportsStressSetting, hrvEnabled = cfgHrv && WearableCapability.HRV in capabilities, temperatureEnabled = cfgTemp && WearableCapability.TEMPERATURE in capabilities, @@ -1857,7 +1896,14 @@ private fun BatteryHistorySection(db: PulseLoopDatabase) { yDomain = 0.0..100.0, accent = PulseColors.success, height = 160.dp, + // Day boundaries on the 7 d view, six-hour marks on 24 h (issue #65). + verticalGridlines = com.pulseloop.service.BatteryProjection.gridlines( + startMs = samples.first().timestampMs, + endMs = samples.last().timestampMs, + ), ) + Spacer(Modifier.height(10.dp)) + BatteryRuntimeEstimate(samples) } } } @@ -3159,3 +3205,40 @@ private fun openHealthConnectPlayStore(context: Context) { } } + +/** + * "About 1d 16h left — draining 2.4 %/h", or an honest silence (issue #65). + * + * Fitted over the current discharge run rather than the whole window, because a week of history + * almost always contains a charge — see [com.pulseloop.service.BatteryProjection]. Shown as an + * estimate, in those words, because that is what it is: a straight line through past drain, which + * says nothing about a day spent measuring more often than the last one. + */ +@Composable +private fun BatteryRuntimeEstimate(samples: List) { + val estimate = remember(samples) { + com.pulseloop.service.BatteryProjection.estimate( + samples.map { com.pulseloop.service.BatteryProjection.Sample(it.timestampMs, it.value) } + ) + } + if (estimate == null) { + Text( + "Not enough of a trend to estimate runtime yet — it appears after a few hours of discharge.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + Column { + Text( + "About ${estimate.label} left", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Estimated from the current discharge at %.1f %%/h.".format(estimate.percentPerHour), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} 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 78132eb..9eea1a3 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt @@ -153,6 +153,13 @@ private fun androidx.compose.foundation.lazy.LazyListScope.sessionPageItems( SleepHypnogram(blocks = blocks, spanMin = session.spanMinutes, startTs = session.startAt) } } + // The individual ring records behind the merged night (issue #68). Only when there is more + // than one — on an unsplit night the session *is* the record and a second card saying so is + // noise. The guard is on the `item` rather than inside it: an item that emits nothing still + // takes its share of the list's 16 dp spacing, which put a 32 dp hole in every single-record + // night. + val runs = com.pulseloop.service.sleepRecordRuns(blocks) + if (runs.size > 1) item { SleepRecordsCard(runs) } item { val byStage = blocks.groupBy { it.stageRaw }.mapValues { (_, b) -> b.sumOf { it.durationMinutes } } SleepStageSummaryCards( @@ -961,3 +968,54 @@ private const val LABEL_GUTTER_DP = 32f private const val LABEL_BASELINE_NUDGE_DP = 7f /** Clearance between the scrubbed lane and the readout pill. */ private const val PILL_OFFSET_ABOVE_LANE_DP = 30f + +/** + * The ring's own sleep records behind a merged night (issue #68). + * + * A ring closes a session when the wearer gets up and opens a new one when they settle, so one + * night can arrive as two or three records minutes apart. Merging them is right — that is the night, + * and the headline stays the merged figure — but the records are real information the merge hides, + * and the vendor app keeps each as its own row. Shown underneath rather than instead. + */ +@Composable +private fun SleepRecordsCard(runs: List) { + VisualizationCard( + eyebrow = "Records", + title = "The ring recorded this night in ${runs.size} parts", + legend = false, + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + runs.forEachIndexed { index, run -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + "${index + 1}", + fontSize = 11.sp, + color = PulseColors.textMuted, + modifier = Modifier.width(18.dp), + ) + Column(Modifier.weight(1f)) { + Text( + "${SleepFormat.clockTime(run.startAt)} – ${SleepFormat.clockTime(run.endAt)}", + fontSize = 14.sp, + color = PulseColors.textPrimary, + ) + Text( + "Asleep ${SleepFormat.duration(run.asleepMinutes)}", + fontSize = 12.sp, + color = PulseColors.textMuted, + ) + } + } + if (index < runs.lastIndex) { + val gapMinutes = ((runs[index + 1].startAt - run.endAt) / 60_000L).toInt() + Text( + "Awake ${SleepFormat.duration(gapMinutes)} between", + fontSize = 12.sp, + color = PulseColors.textMuted, + modifier = Modifier.padding(start = 18.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index d850585..b750606 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -2,6 +2,7 @@ package com.pulseloop.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pulseloop.data.ActivityBucketDeletion import com.pulseloop.data.DemoDataPolicy import com.pulseloop.data.PulseLoopDatabase import com.pulseloop.data.dao.Bucket @@ -10,6 +11,7 @@ import com.pulseloop.data.entity.* import com.pulseloop.ring.* import com.pulseloop.coach.summaries.CoachSummaryKind import com.pulseloop.service.DailyCalorieEstimator +import com.pulseloop.service.DerivedStress import com.pulseloop.service.HeartRateZones import com.pulseloop.service.SleepCoach import com.pulseloop.service.SleepInsights @@ -447,6 +449,25 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { } } + /** + * The individual buckets behind one day's total, newest first (issue #70). + * + * A ring logs activity in intraday blocks, so a day's steps are a couple of dozen rows rather + * than one figure — which is what makes a single inflated block (the ring carried rather than + * worn, a rough car journey) removable at all. + */ + suspend fun bucketsForDay(day: Long): List = + try { db.activityBucketDao().byDay(day).sortedByDescending { it.startEpoch } } + catch (_: Exception) { emptyList() } + + /** Delete one bucket and restate its day. Returns the day's remaining buckets. */ + suspend fun deleteBucket(startEpoch: Long): List { + val day = TimeUtil.startOfDayLocal(startEpoch) + try { ActivityBucketDeletion.delete(db, startEpoch) } catch (_: Exception) {} + refreshCurrentDay() + return bucketsForDay(day) + } + suspend fun reloadGoals() { try { db.userGoalDao().get()?.let { goal -> @@ -489,6 +510,8 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore val spo2Samples: List = emptyList(), val hrvSamples: List = emptyList(), val stressSamples: List = emptyList(), + /** The stress series came from HRV, not from the ring (issue #67). Never shown unlabelled. */ + val stressIsDerived: Boolean = false, val fatigueSamples: List = emptyList(), val tempSamples: List = emptyList(), val latestHr: Int? = null, @@ -612,18 +635,40 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore val gluc = if (caps.contains(WearableCapability.BLOOD_SUGAR)) series(MeasurementKind.BLOOD_SUGAR) else emptyList() val userProfile = db.userProfileDao().get() + // Issue #67: a ring whose hardware never answers a stress query (the R100 — 22 sends, 0 + // replies) advertises STRESS anyway, because the capability list is a static per-family + // constant rather than something that ring confirmed, and the user gets a card that can + // never fill. Where the ring returns HRV but no stress at all, derive one — labelled as + // derived everywhere it is shown, because the ring did not measure it. + // + // "No stress at all" is a question about the ring, so it is asked of the whole history and + // not of this 24 h window. An empty window happens for ordinary reasons — the monitor + // switched off for a day, a ring re-paired this morning, a quiet night — and on a ring that + // *does* measure stress the card would then show a derived number under the footnote "your + // ring doesn't measure stress", which is simply false about that ring. + val hrvValues = hrv.map { it.value } + val ringEverReportedStress = db.measurementDao().hasReal(MeasurementKind.STRESS.name) + val derived = if (stress.isEmpty() && !ringEverReportedStress && hrvValues.isNotEmpty()) + DerivedStress.scored(hrvValues) else emptyList() + // Scores carry the timestamp of the HRV reading each was derived from — the chart, the + // card's headline value and its gauge all read the series, so a value list on its own left + // every one of them empty under a footnote saying where the number came from. + val derivedStressSeries = derived.map { (i, score) -> VitalSample(hrv[i].timestamp, score.toDouble()) } + val derivedStress = derivedStressSeries.map { it.value } + return VitalsState( hrSamples = hr.map { it.value }, spo2Samples = spo2.map { it.value }, hrvSamples = hrv.map { it.value }, - stressSamples = stress.map { it.value }, + stressSamples = stress.map { it.value }.ifEmpty { derivedStress }, + stressIsDerived = stress.isEmpty() && derivedStress.isNotEmpty(), fatigueSamples = fatigue.map { it.value }, tempSamples = temp.map { it.value }, latestHr = hr.lastOrNull()?.value?.toInt(), restingHr = HeartRateZones.restingHeartRate(hr.map { it.value }), latestSpo2 = spo2.lastOrNull()?.value?.toInt(), latestHrv = hrv.lastOrNull()?.value, - latestStress = stress.lastOrNull()?.value, + latestStress = stress.lastOrNull()?.value ?: derivedStress.lastOrNull(), latestFatigue = fatigue.lastOrNull()?.value, latestTemp = temp.lastOrNull()?.value, // Latest = the series' last sample (iOS `inputs.systolic.last`) — demo seeds today's @@ -641,7 +686,7 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore hrSeries = hr.map { VitalSample(it.timestamp, it.value) }, spo2Series = spo2.map { VitalSample(it.timestamp, it.value) }, hrvSeries = hrv.map { VitalSample(it.timestamp, it.value) }, - stressSeries = stress.map { VitalSample(it.timestamp, it.value) }, + stressSeries = stress.map { VitalSample(it.timestamp, it.value) }.ifEmpty { derivedStressSeries }, fatigueSeries = fatigue.map { VitalSample(it.timestamp, it.value) }, tempSeries = temp.map { VitalSample(it.timestamp, it.value) }, bpSysSeries = bpSys.map { VitalSample(it.timestamp, it.value) }, @@ -961,6 +1006,10 @@ class VitalDetailViewModel( * (issue #60). Demo/seeded rows are included: a user clearing seeded noise out of a * chart is the same gesture as removing a bad measurement. */ val readings: List = emptyList(), + /** This chart is derived rather than measured (issue #67) — carried so the screen says so. + * A derived figure shown as a measurement would be worse than the empty chart it replaces, + * which is why it travels with the data instead of being re-inferred at the display layer. */ + val isDerived: Boolean = false, ) /** @@ -1219,12 +1268,23 @@ class VitalDetailViewModel( // Every reading, at its real timestamp — no averaging. val samples = dao.range(kindName, windowStart, windowEnd) - val times = samples.map { it.timestamp } - val points = samples.map { convert(it.value) } + // Issue #67: the Vitals card derives stress from HRV for a ring whose hardware reports + // none, so the detail it opens has to show the same series — a filled card over an + // empty chart reads as a bug in the card. Same gate as the card: only where the ring + // has never returned a stress reading at all, never blended with hardware values. + val derived = if (kind == MeasurementKind.STRESS && samples.isEmpty() && !dao.hasReal(kindName)) + derivedStressIn(windowStart, windowEnd) else emptyList() + val times = if (derived.isNotEmpty()) derived.map { it.timestampMs } else samples.map { it.timestamp } + val points = if (derived.isNotEmpty()) derived.map { it.value } else samples.map { convert(it.value) } val labels = buildLabels(times, period) val prevSamples = dao.range(kindName, prevStart, prevEnd) - val prevAvg = if (prevSamples.isNotEmpty()) convert(prevSamples.map { it.value }.average()) else null + val prevDerived = if (derived.isNotEmpty()) derivedStressIn(prevStart, prevEnd) else emptyList() + val prevAvg = when { + prevDerived.isNotEmpty() -> prevDerived.map { it.value }.average() + prevSamples.isNotEmpty() -> convert(prevSamples.map { it.value }.average()) + else -> null + } val thisAvg = if (points.isNotEmpty()) points.average() else null val range = if (points.isNotEmpty()) points.max() - points.min() else 1.0 val trend = computeTrend(thisAvg, prevAvg, range) @@ -1263,11 +1323,26 @@ class VitalDetailViewModel( resting = resting, trend = trend, engineZones = engineZones, + isDerived = derived.isNotEmpty(), loading = false, ) } } } + /** + * The derived stress series across one window (issue #67), timestamped by the HRV reading each + * score came from. + * + * These are not rows: nothing stored them and [deleteReading] has nothing to remove, which is + * why the readings list below stays empty on a derived chart. + */ + private suspend fun derivedStressIn(from: Long, to: Long): List { + val hrv = db.measurementDao().range(MeasurementKind.HRV.name, from, to) + if (hrv.isEmpty()) return emptyList() + return com.pulseloop.service.DerivedStress.scored(hrv.map { it.value }) + .map { (i, score) -> VitalSample(hrv[i].timestamp, score.toDouble()) } + } + /** * Remove one reading from the record (issue #60). * diff --git a/app/src/test/java/com/pulseloop/data/ActivityBucketDeletionTest.kt b/app/src/test/java/com/pulseloop/data/ActivityBucketDeletionTest.kt new file mode 100644 index 0000000..3f61316 --- /dev/null +++ b/app/src/test/java/com/pulseloop/data/ActivityBucketDeletionTest.kt @@ -0,0 +1,45 @@ +package com.pulseloop.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * What a deleted activity bucket does to the ring's own cumulative day counter (issue #70). + * + * The tombstone and the restate both need Room; this is the rule that doesn't, and it is the one + * the deletion actually hung on — the history path honoured the tombstone while the live path + * ratcheted the deleted block straight back in from the ring's running total. + */ +class ActivityBucketDeletionTest { + + @Test + fun `with nothing deleted the ring's counter ratchets as before`() { + assertEquals(8_000, ActivityBucketDeletion.ratchetAgainstRing(7_500, 8_000, 0)) + } + + @Test + fun `a counter behind the stored total never drags the day down`() { + assertEquals(8_000, ActivityBucketDeletion.ratchetAgainstRing(8_000, 7_900, 0)) + } + + /** The scenario: 1,000 steps deleted out of today's 8,000, then the next live frame lands. */ + @Test + fun `the deleted block does not come back on the next live frame`() { + val afterDelete = 7_000 + + assertEquals(afterDelete, ActivityBucketDeletion.ratchetAgainstRing(afterDelete, 8_000, 1_000)) + } + + /** Steps taken after the deletion still count — the day is corrected, not frozen. */ + @Test + fun `the day keeps climbing from the corrected total`() { + assertEquals(7_500, ActivityBucketDeletion.ratchetAgainstRing(7_000, 8_500, 1_000)) + } + + /** The ring resets its own counter at midnight; a stale deficit must not push a day negative. */ + @Test + fun `a deficit larger than the counter floors at zero`() { + assertEquals(0, ActivityBucketDeletion.ratchetAgainstRing(0, 200, 1_000)) + assertEquals(0.0, ActivityBucketDeletion.ratchetAgainstRing(0.0, 150.0, 900.0), 0.001) + } +} diff --git a/app/src/test/java/com/pulseloop/data/MeasurementDeletionTest.kt b/app/src/test/java/com/pulseloop/data/MeasurementDeletionTest.kt index e96bac0..f180dac 100644 --- a/app/src/test/java/com/pulseloop/data/MeasurementDeletionTest.kt +++ b/app/src/test/java/com/pulseloop/data/MeasurementDeletionTest.kt @@ -25,6 +25,7 @@ class MeasurementDeletionTest { it.measurementId.startsWith(MeasurementDeletionDao.SPOT_ID_PREFIX) && it.timestamp in from..to } + override suspend fun isActivityBucketDeleted(id: String) = id in rows override suspend fun insertAll(rows: List) { rows.forEach { this.rows[it.measurementId] = it } } @@ -134,6 +135,57 @@ class MeasurementDeletionTest { ) } + /** + * Issue #70: an activity bucket is keyed by its own start time and upserted, for the same + * reason a history reading is — so the next sync of that day writes a deleted one straight + * back unless it is remembered. + */ + @Test + fun `a deleted activity bucket is remembered and suppresses its rewrite`() = runTest { + val dao = FakeDeletionDao() + val start = 1_700_000_000_000L + + dao.recordActivity(listOf(start)) + + assertEquals(1, dao.rows.size) + assertTrue(dao.isActivityBucketDeleted(MeasurementDeletionDao.activityBucketId(start))) + assertEquals( + "a neighbouring block is a different record", + false, dao.isActivityBucketDeleted(MeasurementDeletionDao.activityBucketId(start + 1_800_000L)), + ) + } + + /** Re-deleting the same bucket replaces its tombstone rather than adding one. */ + @Test + fun `an activity tombstone is keyed deterministically`() = runTest { + val dao = FakeDeletionDao() + val start = 1_700_000_000_000L + + repeat(3) { dao.recordActivity(listOf(start)) } + + assertEquals(1, dao.rows.size) + } + + /** An activity tombstone must not be mistaken for a measurement one, in either direction. */ + @Test + fun `activity and measurement tombstones cannot collide`() = runTest { + val dao = FakeDeletionDao() + val at = 1_700_000_000_000L + dao.recordActivity(listOf(at)) + + assertEquals( + "a history reading at the same instant is untouched", + false, dao.isDeleted(historyMeasurementId(MeasurementKind.HEART_RATE, at)), + ) + assertEquals( + "and it is not a spot range tombstone either", + false, dao.isSpotDeleted(MeasurementKind.HEART_RATE.name, at - 90_000, at + 90_000), + ) + assertTrue( + MeasurementDeletionDao.activityBucketId(at).startsWith(MeasurementDeletionDao.ACTIVITY_ID_PREFIX), + ) + } + /** * The id scheme lives in `EventPersistenceSubscriber` and the prefix that recognises it lives * on the DAO. If those two ever drift, deletes of history rows silently stop sticking and the diff --git a/app/src/test/java/com/pulseloop/service/BatteryProjectionTest.kt b/app/src/test/java/com/pulseloop/service/BatteryProjectionTest.kt new file mode 100644 index 0000000..a56cdc0 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/BatteryProjectionTest.kt @@ -0,0 +1,131 @@ +package com.pulseloop.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Issue #65: a runtime estimate from the ring's own battery history. */ +class BatteryProjectionTest { + + private val t0 = 1_725_000_000_000L + private fun hours(h: Double) = t0 + (h * 3_600_000).toLong() + + /** A steady drain: the slope is the drain rate and the projection is what's left over it. */ + @Test + fun `a steady discharge projects the time to empty`() { + // 100 % falling 2 %/h for 10 hours → 80 % left, 40 h remaining. + val samples = (0..10).map { BatteryProjection.Sample(hours(it.toDouble()), 100.0 - 2.0 * it) } + + val estimate = BatteryProjection.estimate(samples) + + assertNotNull(estimate) + assertEquals(2.0, estimate!!.percentPerHour, 0.001) + assertEquals(40.0, estimate.hoursRemaining, 0.01) + assertEquals("1d 16h", estimate.label) + } + + /** + * The case that makes a whole-window fit useless: a 7-day window almost always contains a + * charge, and a line through "falling, then 100 %, then falling" describes when the user + * plugged in rather than how fast the ring drains. + */ + @Test + fun `a charge starts a new run and only the run after it is fitted`() { + val discharge = (0..10).map { BatteryProjection.Sample(hours(it.toDouble()), 60.0 - 2.0 * it) } + val recharged = (0..10).map { BatteryProjection.Sample(hours(11.0 + it), 100.0 - 1.0 * it) } + + val run = BatteryProjection.currentDischargeRun(discharge + recharged) + val estimate = BatteryProjection.estimate(discharge + recharged) + + assertEquals("the run starts at the charge", 100.0, run.first().percent, 0.001) + assertEquals(11, run.size) + assertNotNull(estimate) + assertEquals("the post-charge rate, not the average of both", 1.0, estimate!!.percentPerHour, 0.001) + } + + /** Coarse reporting wobbles by a point or two at a plateau; that is not a charge. */ + @Test + fun `a small wobble does not split the discharge run`() { + val samples = listOf( + BatteryProjection.Sample(hours(0.0), 80.0), + BatteryProjection.Sample(hours(2.0), 78.0), + BatteryProjection.Sample(hours(4.0), 79.0), // +1, noise + BatteryProjection.Sample(hours(6.0), 76.0), + BatteryProjection.Sample(hours(8.0), 74.0), + ) + + assertEquals(5, BatteryProjection.currentDischargeRun(samples).size) + assertNotNull(BatteryProjection.estimate(samples)) + } + + @Test + fun `no estimate from too few samples, too short a span, or a flat line`() { + val tooFew = (0..2).map { BatteryProjection.Sample(hours(it.toDouble()), 90.0 - it) } + assertNull(BatteryProjection.estimate(tooFew)) + + val tooShort = (0..5).map { BatteryProjection.Sample(hours(it * 0.1), 90.0 - it) } + assertNull("half an hour of readings is not a trend", BatteryProjection.estimate(tooShort)) + + val flat = (0..10).map { BatteryProjection.Sample(hours(it.toDouble()), 90.0) } + assertNull("a flat line has nothing to project", BatteryProjection.estimate(flat)) + } + + /** + * The sample/span/fall gates are all about the run's shape and none of them bounds how *slow* + * it may be. Firmware that reports in 5 % steps clears every one of them over a 7 d window and + * projects a runtime no ring has. + */ + @Test + fun `no estimate from a drain too slow to be one`() { + // 80 → 75 across a week: 4 samples, 168 h, a 5-point fall — and 0.03 %/h. + val coarse = listOf(80.0, 79.0, 77.0, 75.0).mapIndexed { i, percent -> + BatteryProjection.Sample(hours(i * 56.0), percent) + } + + assertNull("104 days of runtime is not an estimate", BatteryProjection.estimate(coarse)) + } + + /** While charging there is no depletion to project, and guessing one would be a lie. */ + @Test + fun `no estimate while the battery is rising`() { + val charging = (0..10).map { BatteryProjection.Sample(hours(it * 0.5), 40.0 + 2.0 * it) } + assertNull(BatteryProjection.estimate(charging)) + } + + @Test + fun `a day window grids on the six-hour marks and a week window on midnights`() { + val zone = java.time.ZoneId.of("UTC") + val dayStart = java.time.ZonedDateTime.of(2026, 9, 10, 3, 20, 0, 0, zone).toInstant().toEpochMilli() + val dayEnd = dayStart + 24 * 3_600_000L + + val hourly = BatteryProjection.gridlines(dayStart, dayEnd, zone) + assertTrue(hourly.isNotEmpty()) + hourly.forEach { + val t = java.time.Instant.ofEpochMilli(it).atZone(zone) + assertEquals("on the hour", 0, t.minute) + assertEquals("on a six-hour mark", 0, t.hour % 6) + } + + val weekEnd = dayStart + 7 * 24 * 3_600_000L + val daily = BatteryProjection.gridlines(dayStart, weekEnd, zone) + assertEquals(7, daily.size) + daily.forEach { + val t = java.time.Instant.ofEpochMilli(it).atZone(zone) + assertEquals("local midnight", 0, t.hour) + assertEquals(0, t.minute) + } + } + + /** Every mark must sit inside the plotted window, or it draws off the chart. */ + @Test + fun `gridlines stay inside the window`() { + val start = t0 + val end = t0 + 24 * 3_600_000L + BatteryProjection.gridlines(start, end).forEach { + assertTrue(it > start && it <= end) + } + assertEquals(emptyList(), BatteryProjection.gridlines(end, start)) + } +} diff --git a/app/src/test/java/com/pulseloop/service/DerivedStressTest.kt b/app/src/test/java/com/pulseloop/service/DerivedStressTest.kt new file mode 100644 index 0000000..39a45b1 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/DerivedStressTest.kt @@ -0,0 +1,104 @@ +package com.pulseloop.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Issue #67: a stress figure derived from HRV for rings that never report one. */ +class DerivedStressTest { + + /** 40 ms median, spread of 5. */ + private val baseline = listOf(30.0, 35.0, 35.0, 40.0, 40.0, 40.0, 40.0, 45.0, 45.0, 50.0, 40.0, 38.0) + + @Test + fun `a reading at the user's own median scores mid-scale`() { + val reading = DerivedStress.score(40.0, baseline) + assertEquals(50, reading!!.score) + } + + @Test + fun `lower HRV than usual reads as more stress and higher as less`() { + val stressed = DerivedStress.score(30.0, baseline)!!.score + val calm = DerivedStress.score(50.0, baseline)!!.score + + assertTrue("below the median is above mid-scale", stressed > 50) + assertTrue("above the median is below mid-scale", calm < 50) + assertEquals("and symmetric about it", 100, stressed + calm) + } + + /** + * The score is relative to the person: HRV varies several-fold between individuals, so the same + * absolute reading must not label one person calm and another stressed. + */ + @Test + fun `the same HRV scores differently against different people`() { + val lowHrvPerson = List(12) { 20.0 + (it % 3) } + val highHrvPerson = List(12) { 90.0 + (it % 3) } + + val againstLow = DerivedStress.score(40.0, lowHrvPerson)!!.score + val againstHigh = DerivedStress.score(40.0, highHrvPerson)!!.score + + assertTrue("well above their normal reads calm", againstLow < 50) + assertTrue("well below their normal reads stressed", againstHigh > 50) + } + + @Test + fun `no score without enough baseline`() { + assertNull(DerivedStress.score(40.0, baseline.take(DerivedStress.MIN_BASELINE_SAMPLES - 1))) + assertNull(DerivedStress.score(40.0, emptyList())) + assertNull("a missing reading is not a zero", DerivedStress.score(0.0, baseline)) + } + + /** Outliers are what ring HRV history is full of; one must not redefine the scale. */ + @Test + fun `an extreme outlier does not swamp the baseline`() { + val withOutlier = baseline + 400.0 + + val normal = DerivedStress.score(40.0, baseline)!!.score + val withJunk = DerivedStress.score(40.0, withOutlier)!!.score + + assertTrue("the median-based scale barely moves", kotlin.math.abs(normal - withJunk) <= 5) + } + + @Test + fun `a flat baseline still responds rather than snapping to the ends`() { + val flat = List(12) { 50.0 } + + val slightlyLow = DerivedStress.score(48.0, flat)!!.score + + assertTrue(slightlyLow in 51..99) + } + + @Test + fun `the score is always bounded and always marked derived`() { + assertEquals(100, DerivedStress.score(1.0, baseline)!!.score) + assertEquals(0, DerivedStress.score(500.0, baseline)!!.score) + assertTrue(DerivedStress.score(40.0, baseline)!!.derived) + } + + /** The series scores each reading against only what came before it — no hindsight. */ + @Test + fun `the series skips readings with no baseline behind them`() { + val hrv = List(20) { 40.0 } + val series = DerivedStress.series(hrv) + + assertEquals(20 - DerivedStress.MIN_BASELINE_SAMPLES, series.size) + } + + /** + * Because the first readings are skipped, a caller cannot zip the scores back onto the HRV + * series positionally — the index has to travel with the score or every point on the chart + * lands at the wrong time. + */ + @Test + fun `scored names the reading each score came from`() { + val hrv = List(20) { 40.0 } + + val scored = DerivedStress.scored(hrv) + + assertEquals(DerivedStress.MIN_BASELINE_SAMPLES, scored.first().first) + assertEquals(hrv.lastIndex, scored.last().first) + assertEquals(DerivedStress.series(hrv), scored.map { it.second }) + } +} diff --git a/app/src/test/java/com/pulseloop/service/SleepRecordRunsTest.kt b/app/src/test/java/com/pulseloop/service/SleepRecordRunsTest.kt new file mode 100644 index 0000000..e900700 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/SleepRecordRunsTest.kt @@ -0,0 +1,153 @@ +package com.pulseloop.service + +import com.pulseloop.data.entity.SleepStageBlockEntity +import com.pulseloop.ring.SleepStage +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Issue #68: a split night's individual ring records, recovered from the merged session's blocks. + */ +class SleepRecordRunsTest { + + private val base = 1_725_408_720_000L // 00:12 + + /** A block with no record stamp — a row written before the column existed (the gap fallback). */ + private fun block(startMinute: Int, minutes: Int, stage: SleepStage = SleepStage.LIGHT) = + SleepStageBlockEntity( + id = "b$startMinute", + sessionId = "s", + startAt = base + startMinute * 60_000L, + startMinute = startMinute, + durationMinutes = minutes, + stageRaw = stage.name, + ) + + /** A block that knows which ring record it arrived in, named by that record's start minute. */ + private fun stamped( + startMinute: Int, + minutes: Int, + recordStartMinute: Int, + stage: SleepStage = SleepStage.LIGHT, + ) = block(startMinute, minutes, stage).copy(recordStartAt = base + recordStartMinute * 60_000L) + + /** The reporter's night: two records nine minutes apart, merged into one stored session. */ + @Test + fun `a night split by a wake comes back as two records`() { + val first = listOf(block(0, 124), block(124, 62, SleepStage.DEEP)) // 00:12–03:18 + val second = listOf(block(195, 119), block(314, 124, SleepStage.DEEP)) // 03:27–08:05 + + val runs = sleepRecordRuns(first + second) + + assertEquals(2, runs.size) + assertEquals(base, runs[0].startAt) + assertEquals(186, runs[0].spanMinutes) + assertEquals(base + 195 * 60_000L, runs[1].startAt) + assertEquals(243, runs[1].spanMinutes) + assertEquals(186, runs[0].asleepMinutes) + } + + /** + * The case that would break every unsplit night: blocks rounded onto the minute grid can leave + * a one-minute seam, and that is not a record boundary. + */ + @Test + fun `a one-minute seam does not split a record`() { + val blocks = listOf(block(0, 60), block(61, 60), block(122, 60)) + + val runs = sleepRecordRuns(blocks) + + assertEquals(1, runs.size) + assertEquals(3, runs.single().blocks.size) + } + + @Test + fun `an unsplit night is a single run and an empty night is none`() { + assertEquals(1, sleepRecordRuns(listOf(block(0, 120), block(120, 60))).size) + assertEquals(0, sleepRecordRuns(emptyList()).size) + } + + /** Awake stretches inside a record belong to it; only an unclaimed gap divides records. */ + @Test + fun `an awake block inside a record keeps it whole`() { + val blocks = listOf(block(0, 100), block(100, 20, SleepStage.AWAKE), block(120, 100)) + + val runs = sleepRecordRuns(blocks) + + assertEquals(1, runs.size) + assertEquals("awake does not count as asleep", 200, runs.single().asleepMinutes) + assertEquals(220, runs.single().spanMinutes) + } + + /** Three records, which the reporter's Sept 6 night actually had. */ + @Test + fun `three records split on both gaps`() { + val runs = sleepRecordRuns( + listOf(block(0, 60), block(80, 60), block(160, 60)), + ) + assertEquals(3, runs.size) + } + + // ── The stored record boundary (issue #68, rc1 feedback) ──────────────────────────────── + // + // These two are the pair that no gap threshold can satisfy at once: the same one-minute gap + // means "next record" in the first and "rounding seam" in the second. Only the stamp tells + // them apart, which is why the boundary is stored at import rather than inferred here. + + /** + * The reporter's Sept 9: `00:35 → 05:57` then `05:58 → 08:29`. One minute apart, and genuinely + * two records. The gap heuristic merged it and the card disappeared. + */ + @Test + fun `two records one minute apart are two records`() { + val first = listOf(stamped(0, 200, recordStartMinute = 0), stamped(200, 122, 0, SleepStage.DEEP)) + val second = listOf(stamped(323, 151, recordStartMinute = 323)) + + val runs = sleepRecordRuns(first + second) + + assertEquals(2, runs.size) + assertEquals(base, runs[0].startAt) + assertEquals(322, runs[0].spanMinutes) + assertEquals(base + 323 * 60_000L, runs[1].startAt) + assertEquals(151, runs[1].spanMinutes) + } + + /** The same one-minute gap inside a single record stays one record. */ + @Test + fun `a one-minute seam within one record is still one record`() { + val blocks = listOf( + stamped(0, 60, recordStartMinute = 0), + stamped(61, 60, recordStartMinute = 0), + stamped(122, 60, recordStartMinute = 0), + ) + + val runs = sleepRecordRuns(blocks) + + assertEquals(1, runs.size) + assertEquals(3, runs.single().blocks.size) + } + + @Test + fun `records come back in time order however the blocks arrive`() { + val runs = sleepRecordRuns( + listOf(stamped(323, 151, 323), stamped(0, 200, 0)), + ) + + assertEquals(2, runs.size) + assertEquals(base, runs[0].startAt) + } + + /** + * A night half-written before the column existed has no consistent boundary to read: one + * stamped record beside a legacy block would look like two records whatever the truth, so the + * whole night falls back to the gap rule. + */ + @Test + fun `a night with any unstamped block falls back to the gap rule`() { + val blocks = listOf(stamped(0, 60, recordStartMinute = 0), block(61, 60)) + + val runs = sleepRecordRuns(blocks) + + assertEquals("the one-minute seam does not split under the fallback", 1, runs.size) + } +} diff --git a/app/src/test/java/com/pulseloop/service/SpotMeasurementGateTest.kt b/app/src/test/java/com/pulseloop/service/SpotMeasurementGateTest.kt index e92c0ff..7452b62 100644 --- a/app/src/test/java/com/pulseloop/service/SpotMeasurementGateTest.kt +++ b/app/src/test/java/com/pulseloop/service/SpotMeasurementGateTest.kt @@ -166,4 +166,73 @@ class SpotMeasurementGateTest { val hr = gate.begin(YCBTMeasurementMode.HEART_RATE) assertNull("a late completion must not end the next measurement", gate.completedSuccessfully(hr)) } + + // ── The ring's *failure* verdict ──────────────────────────────────────────────────────── + // + // Every completion case above passed `success = true`, and `RingSyncCoordinator` has no test + // harness, so `04 0e {mode, 02}` — the ring saying the measurement failed — was never once + // executed by the suite. That is the branch a rc1 tester reported as a reading stored from a + // run the ring had called a failure (issue #66), and nothing here would have caught a drift + // in it. `false` and `null` are also the two answers a caller must not confuse: null means + // "still measuring, keep the window as the bound", false means "abort, store nothing". + + @Test + fun `a failure verdict is reported as a failure and not as silence`() { + val gate = SpotMeasurementGate() + val hr = gate.begin(YCBTMeasurementMode.HEART_RATE) + + gate.noteCompleted(YCBTMeasurementMode.HEART_RATE, success = false) + + assertEquals("a failed run is `false`, not `null`", false, gate.completedSuccessfully(hr)) + assertFalse("a failure is not a refusal — it has its own abort path", gate.isRejected(hr)) + } + + @Test + fun `a failure of a different mode cannot fail the one in flight`() { + val gate = SpotMeasurementGate() + val hr = gate.begin(YCBTMeasurementMode.HEART_RATE) + + gate.noteCompleted(YCBTMeasurementMode.SPO2, success = false) + + assertNull("only the measurement the ring named may be ended", gate.completedSuccessfully(hr)) + } + + /** Legs really do overlap here, and one leg's failure must not abort another's good run. */ + @Test + fun `a failure ends only the measurement it names when two are in flight`() { + val gate = SpotMeasurementGate() + val hr = gate.begin(YCBTMeasurementMode.HEART_RATE) + val spo2 = gate.begin(YCBTMeasurementMode.SPO2) + + gate.noteCompleted(YCBTMeasurementMode.SPO2, success = false) + gate.noteCompleted(YCBTMeasurementMode.HEART_RATE, success = true) + + assertEquals(false, gate.completedSuccessfully(spo2)) + assertEquals(true, gate.completedSuccessfully(hr)) + } + + /** The ring's first word on a run is its last: a stray success after a failure must not turn + * a run the ring failed into one whose samples are worth settling. */ + @Test + fun `a failure is not overwritten by a later success`() { + val gate = SpotMeasurementGate() + val hr = gate.begin(YCBTMeasurementMode.HEART_RATE) + + gate.noteCompleted(YCBTMeasurementMode.HEART_RATE, success = false) + gate.noteCompleted(YCBTMeasurementMode.HEART_RATE, success = true) + + assertEquals("the run was already decided", false, gate.completedSuccessfully(hr)) + } + + @Test + fun `a failure cannot leak into the next measurement on the same mode`() { + val gate = SpotMeasurementGate() + val first = gate.begin(YCBTMeasurementMode.HEART_RATE) + gate.noteCompleted(YCBTMeasurementMode.HEART_RATE, success = false) + gate.end(first) + + val retry = gate.begin(YCBTMeasurementMode.HEART_RATE) + + assertNull("a retry starts with the ring having said nothing", gate.completedSuccessfully(retry)) + } }