feat: activity record deletion (#70), split measure controls + SpO₂ interval (#66), battery grid & runtime estimate (#65), split-night records (#68), derived stress (#67) - #71
Open
foureight84 wants to merge 8 commits into
Conversation
The rest of #60's request, asked for by the same reporter. The vitals side got a READINGS list with a delete per entry; activity had no equivalent, so a day's total was a single figure with no way to see or remove the blocks behind it. A ring logs activity in intraday blocks, so a day's steps are a couple of dozen rows. Anything that inflates one — the ring carried rather than worn, a car journey on a rough road — is otherwise stuck in that total for good. A RECORDS card on the Activity screen lists today's blocks with their times and step counts, each deletable behind a confirmation that says what the day's total will drop by. Collapsed by default, loaded only when opened, and paged at 50. Deletion only, as in #60: a recorded value can be removed, never edited into a different one. Two rules make it stick, and both live in `ActivityBucketDeletion` rather than in any caller: - **A tombstone, not just a DELETE.** `activity_buckets` is keyed by the block's start time and written with `upsert`, deliberately — re-syncing a day the ring still holds must replace each block rather than accumulate it, or the total drifts upward every pass. That idempotence is exactly what would undo the deletion. `MeasurementDeletionDao.recordActivity` remembers it under an `activity:<startEpoch>` key in the same table the vitals tombstones use, so it rides the archive with them and needs no migration; the write path checks before every bucket write. - **The day is restated without the ratchet.** A day's total is the sum of its blocks, but *today* is ratcheted against the existing row because the live cumulative step count legitimately leads the block history. A deletion is the one case where the total must be allowed to fall — ratcheting would remove the row and leave the steps it contributed sitting in the headline, which reads as the delete having silently failed. 1315 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Split off from #59. The Vitals "Measure" button ran every capability-gated leg in sequence, and on a YCBT ring those legs have almost nothing in common: heart rate runs to a 45 s ceiling with the ring self-terminating around 35 s, while blood oxygen runs to 75 s, produces its first sample at t+13, and has a 24 s silence in the middle. Treating them as one measurement means waiting out both to get either — a tester's run started at 123 s and heart rate landed with 85 s still on the clock. A ring that runs the two legs separately now offers a control for each, with the countdown for the leg actually running rather than a single number covering two very different waits. The combined flow (one `0x23` packet) keeps its single button, because there it genuinely is one measurement. The two still cannot overlap: the ring serves one measurement at a time and the live-sample gate is one switch per kind, so `spotMeasureInProgress` disables both controls while either is running. That is a visible refusal rather than the silent no-op each leg's own state guard would have produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asked for on #66 by the reporter, alongside the split controls. Measurement Frequency offered an interval slider for all-day heart rate but only an on/off toggle for blood oxygen — so SpO2 silently inherited whatever heart rate was set to. On a ring running both monitors hourly that is most of the battery: full to about a quarter inside a day. Nothing in the protocol was in the way. The YCBT monitor command takes the same shape for every vital (`01 <key> {enable, interval}`, `0x0c` heart, `0x26` blood oxygen) and the encoder was already sending an interval byte for blood oxygen — it just passed the heart-rate one. It now passes its own where one is set. 0 means "follow heart rate", which is exactly what every existing row did implicitly, so the v25 -> v26 column backfills losslessly and the bottom of the slider is that rather than a shorter interval than the ring will honour. The firmware floors the interval at its own minimum whatever is asked for (30 min on the reporter's ring), which the existing clamp already reflects. 1315 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…65) Both halves of @aheider's request. The chart now rules a faint vertical line at each day boundary on the 7 d view and every six hours on 24 h, so a reader can tell *when* a drop happened rather than only its shape, and a runtime estimate sits under it. **The estimate is fitted over the current discharge run, not the window.** A least-squares line is what was asked for, but a 7 d window almost always contains a charge, and a line through "40 % falling, then 100 %, then falling again" has a slope that describes when the user happened to plug in. The run since the last charge is the only stretch whose slope is a drain rate. A rise of three points or more is the boundary; less than that is coarse reporting wobbling at a plateau (some firmware reports in 5 % steps) and must not split a run. It refuses to answer rather than guessing: fewer than four samples, under 90 minutes of span, under two points of fall, or a flat or rising line all produce "not enough of a trend yet". A number on screen is read as a promise, and battery life is the kind of thing someone plans a trip around, so an unearned estimate is worse than none. The copy says "About …", "Estimated from the current discharge", for the same reason — it is a straight line through past drain and says nothing about a day spent measuring more often than the last one. `BatteryProjection` is pure and tested (steady discharge, a charge mid-window, plateau wobble, each refusal case, and both gridline shapes). The chart's gridlines are opt-in, so no other chart changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split off from #63, where it was a fair ask and was deferred. 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 merge is what #63 fixed — but the individual records are real information the merge then hides, and the vendor app keeps each as its own row. The sleep detail screen now shows them under the hypnogram: each record's clock range and time asleep, with the wake between them stated. The merged session stays the headline. Shown only when there is more than one record, because on an unsplit night the session *is* the record and a card saying so is noise. 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 no block claims. The threshold is two minutes, which is what separates a real boundary from the one-minute seam left by rounding blocks onto the minute grid — without it every unsplit night would grow a spurious second record. `sleepRecordRuns` is pure and tested: the reporter's two-record night, a three-record night, the one-minute seam, an unsplit night, and an awake stretch inside a record (which belongs to it — only an unclaimed gap divides records). 1327 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split off from #58. 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, not switched off. The app still advertised it, because the capability list is a static per-family constant rather than something that ring confirmed, and the user got a card that could never fill. That ring returns plenty of HRV, and stress is conventionally read from HRV, so the number is derivable. **It is labelled wherever it appears.** The card reads "Estimated from HRV — your ring doesn't measure stress", and `stressIsDerived` travels with the series so no future surface can render it as a measurement by accident. Presenting a derived figure 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. The score is 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; a reading at that user's own median scores 50 and moves inversely from there, scaled by their own spread. 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 — a test pins that a 400 ms reading moves the result by at most five points. Below twelve baseline readings it returns null rather than a default. It fills in only where the ring returned no stress at all; it never overwrites or blends with hardware readings. The series scores each reading against the baseline before it, so the line is what the app could have said at the time. This is the second half of #67. The first half — tracking which capabilities an individual ring has actually confirmed, rather than trusting the family constant — is untouched and still open; this addresses the symptom the reporter saw without widening the capability plumbing. 1335 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…batch Three of these are features that don't reach the user at all: - **Derived stress was computed and never shown** (#67). `stressSeries` was still built from the ring's (empty) stress rows, so the card that carries the "Estimated from HRV" footnote rendered "--" / "No data" over a gauge pinned at 0. `DerivedStress.scored` now returns each score with the index of the HRV reading behind it — the scores skip the first readings for want of a baseline, so a positional zip would shift every point — and the series carries that reading's timestamp. The `>= 10` floor no longer applies to a derived score (its whole range is meaningful; a calm day scores below 10), the detail chart derives its own window, and it says so there in the same disclaimer card BP and glucose use. - **Deleting today's activity bucket undid itself** (#70). The tombstone guards the history path, but the ring's *live* cumulative day counter still includes the deleted block and `upsertActivityDaily` ratchets against it, so a restated 7,000 was 8,000 again on the next frame. `activity_daily` now remembers the day's deficit (v27) and subtracts it before the ratchet. The ring's own calorie figure counted the deleted block and a bucket carries no calories to subtract, so it is dropped in favour of the app's estimate, recomputed at deletion time rather than at the next sync. - **The separate HR/SpO2 controls removed manual BP and HRV** (#66). `measureSpot()` was the only caller of those two legs, so a ring that advertises them lost them entirely. They get their own control. The rest: - A leg's DONE/FAILED persists after a run, so a single-leg measurement reported the *other* leg's stale verdict; the failure check is now scoped to the legs each run starts, and a refusal clears its own state. - The progress bar divided by the whole sweep while counting down one leg (an HR run started 61% full), and the caption always said "heart rate & SpO2". - The new SpO2 interval reached no ring but YCBT: CRP passed `hrIntervalMinutes` to `enableTimingSpO2` — the inheritance bug the feature exists to fix — and Colmi/LuckRing cannot express one at all, so the slider is gated on the protocol rather than printing "sent to ring" over a frame we'd be inventing. QRing's `BloodOxygenSettingReq` has an interval overload its own app never calls; we don't call it either. - "Your ring doesn't measure stress" was decided by an empty 24h window, which goes empty for ordinary reasons; it is now asked of the whole history. - The SpO2 slider reused the HR slider's `steps` over a wider range, putting ticks 5.45 min apart that its 5-minute snap could never land on. - No floor on the battery drain rate: 5%-step firmware over 7 days projected "about 104d 4h left" at "0.0 %/h" from a fit that passed every refusal gate. - The split-night records `item` emitted nothing on an unsplit night but still took its share of the list's spacing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 12, 2026
…it (#68) rc1 feedback: the reporter's Sept 9 night is two ring records, `00:35 → 05:57` and `05:58 → 08:29`, and showed no records card at all. Sept 10 (16 minutes apart) was fine. `sleepRecordRuns` inferred the boundary from a gap no block covers, with `minGapMinutes = 2`. That cannot work on this firmware. It reopens a record about a minute after closing one — 33 seconds on the #63 capture, one minute here — and blocks *within* a record are rounded onto the same minute grid, so a rounding seam between two blocks of one record is exactly as wide as a real record boundary. 2 minutes merges a genuinely split night; 1 minute would grow a spurious second record on every unsplit one. The threshold was never the problem: `sessionId` names the *merged* row, so from stored data alone "next record" and "next block" are the same shape. `SleepStageBlockEntity.recordStartAt` (v28) carries the declared start of the ring record each block arrived in, stamped by `buildStageBlocks` — the one place that knows, because the merge happens immediately afterwards. `sleepRecordRuns` groups by it. The gap rule stays as the fallback for rows written before the column, all-or-nothing per night: one stamped record beside a legacy block would read as two records whatever the truth. This is the fix the #63 note already predicted for the same missing identity ("carrying the originating record's start on each block would allow both"), so AGENTS.md now records the boundary as a stored fact and keeps the discriminating test pair — the same one-minute gap asserted as a boundary in one test and as a seam in another. Also adds the failure-verdict coverage that was missing from `SpotMeasurementGateTest`: every completion case passed `success = true`, and `RingSyncCoordinator` has no harness, so `04 0e {mode, 02}` — the ring saying a spot measurement failed — had never been executed by the suite. That is the branch behind the #66 rc1 report, and nothing would have caught a drift in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #65, closes #66, closes #68, closes #70. Addresses half of #67.
Five feature requests that accumulated during the 2.8.0 cycle, most of them split off from the bug work rather than raised cold. None of it is hardware-tested — every ring-facing change here is behind a capability the reporters' rings advertise, but the measure controls, the SpO₂ interval and the derived stress card all want a look on real hardware before this merges.
1335 tests, 0 failures. Two schema migrations (v25 → v26), so a downgrade past this wipes local data — see the note at the end.
#70 — delete individual activity records
The rest of #60's request, from the same reporter: the vitals side got a READINGS list with a delete per entry, activity had no equivalent. A ring logs activity in intraday blocks, so a day's total is built from a couple of dozen rows, and anything that inflates one — the ring carried rather than worn, a car journey on a rough road — is stuck in that total for good.
A RECORDS card on the Activity screen lists today's blocks with their times and step counts, each deletable behind a confirmation that states what the day's total will drop by. Deletion only, as in #60.
Two rules make it stick, and both live in
ActivityBucketDeletion:DELETE.activity_bucketsis keyed by block start time and upserted deliberately — re-syncing a day must replace each block, not accumulate it, or the total drifts upward every pass. That idempotence is exactly what would undo the deletion. The tombstone goes in the same table the vitals ones use, under anactivity:<startEpoch>key, so it rides the archive with them and needs no migration.#66 — separate HR and SpO₂ controls, and an SpO₂ interval
Separate controls. The legs have almost nothing in common on a YCBT ring: heart rate runs to 45 s with the ring self-terminating near 35, blood oxygen runs to 75 s, first sample at t+13, with a 24 s silence in the middle. One button meant waiting out both to get either — a tester's run started at 123 s and heart rate landed with 85 s still on the clock. Each control now counts down its own leg. The combined
0x23flow keeps its single button, because there it genuinely is one measurement. The two can't overlap: the ring serves one at a time and the live-sample gate is one switch per kind, sospotMeasureInProgressdisables both while either runs — a visible refusal rather than a silent no-op.An interval for blood oxygen (@Albabit's follow-up). Measurement Frequency offered an interval slider for heart rate and only an on/off toggle for SpO₂, so SpO₂ silently inherited the heart-rate interval — on a ring running both hourly, that's most of the battery. Nothing in the protocol was in the way: the monitor command takes the same shape for every vital (
01 <key> {enable, interval}) and the encoder was already sending an interval byte for blood oxygen, it just passed heart rate's.0means "follow heart rate", which is what every existing row did implicitly, so the new column backfills losslessly.#65 — battery grid and runtime estimate
Both halves of @aheider's request: a faint vertical rule at each day boundary on the 7 d view and every six hours on 24 h, and a runtime estimate underneath.
The estimate is fitted over the current discharge run, not the window. A least-squares line is what was asked for, but a 7 d window almost always contains a charge, and a line through "40 % falling, then 100 %, then falling again" has a slope that describes when the user plugged in. A rise of three points or more is the boundary; less is coarse reporting wobbling at a plateau (some firmware reports in 5 % steps) and must not split a run.
It refuses to answer rather than guessing — under four samples, under 90 minutes, under two points of fall, or a flat or rising line all give "not enough of a trend yet". Battery life is the kind of thing someone plans a trip around, so an unearned estimate is worse than none.
#68 — a split night's individual records
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's the night, and the merge is what #63 fixed — but the records are real information the merge hides, and the vendor app keeps each as its own row.
The sleep detail screen now shows them under the hypnogram: each record's clock range and time asleep, with the wake between them stated. The merged session stays the headline, and the card only appears when there's more than one record.
Recovered from stored data rather than re-parsed: since #63 each record's blocks are placed against its own declared bounds, so a boundary is a stretch no block claims. The threshold is two minutes, which is what separates a real boundary from the one-minute seam left by rounding blocks onto the minute grid — without it every unsplit night grows a spurious second record.
#67 — derived stress (half the issue)
The R100 answers neither the stress history query nor its monitor-state read-back — 22 sends, 0 replies, while every other state query answered — so stress there is absent, not switched off. The app advertised it anyway, because the capability list is a static per-family constant, and the user got a card that could never fill.
It is labelled wherever it appears: the card reads "Estimated from HRV — your ring doesn't measure stress", and
stressIsDerivedtravels with the series so no future surface can render it as measured by accident. Presenting a derived figure as a measurement would be worse than the empty card it replaces.Scored against that user's own recent HRV rather than a population — HRV varies several-fold between individuals, so an absolute cutoff labels whole people permanently stressed or calm. Median and median-absolute-deviation, not mean and standard deviation, because ring HRV history is full of outliers; a test pins that a 400 ms reading moves the result by at most five points. Below twelve baseline readings it returns null. It fills in only where the ring returned no stress at all, and never blends with hardware readings.
What I did not do: the first half of #67 — tracking which capabilities an individual ring has actually confirmed, rather than trusting the family constant. That is the deeper fix and it touches the capability plumbing
AGENTS.mdwarns about, so it wants its own change and its own reasoning. I've left #67 open for it.Testing and risk
./gradlew testDebugUnitTest— 1335 tests, 0 failures. New pure, tested logic:BatteryProjection(steady discharge, a charge mid-window, plateau wobble, each refusal case, both gridline shapes),sleepRecordRuns(two- and three-record nights, the one-minute seam, an unsplit night, awake inside a record),DerivedStress(median behaviour, relative-to-the-person scoring, outlier resistance, every refusal), and the activity tombstone rules including that activity and measurement tombstones can't collide.Two migrations, so rollback is destructive. v25 → v26 adds
device_measurement_configs.spo2IntervalMinutes. Anyone moving back to a build older than 2.8.0 hits the destructive downgrade fallback and loses local data; export an archive first.Unverified on hardware. Everything here compiles and is unit-tested, but nobody has run it on a ring. The three worth a real look before merge: the separate measure controls on a YCBT ring, the SpO₂ interval actually being honoured (the firmware floors it at 30 min on @Albabit's ring), and whether the derived stress numbers look sane against a real HRV history rather than a synthetic one.
🤖 Generated with Claude Code