Skip to content

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
mainfrom
feat/issues-65-66-67-68-70
Open

feat: activity record deletion (#70), split measure controls + SpO₂ interval (#66), battery grid & runtime estimate (#65), split-night records (#68), derived stress (#67)#71
foureight84 wants to merge 8 commits into
mainfrom
feat/issues-65-66-67-68-70

Conversation

@foureight84

Copy link
Copy Markdown
Owner

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:

  • A tombstone, not just a DELETE. activity_buckets is 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 an activity:<startEpoch> key, so it rides the archive with them and needs no migration.
  • 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 its steps sitting in the headline, which reads as the delete having silently failed.

#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 0x23 flow 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, so spotMeasureInProgress disables 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. 0 means "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 stressIsDerived travels 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.md warns about, so it wants its own change and its own reasoning. I've left #67 open for it.


Testing and risk

./gradlew testDebugUnitTest1335 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

foureight84 and others added 7 commits September 12, 2026 01:26
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>
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant