Skip to content

feat(oura): overnight readings, PI=255 inclusion, curated upstream fixes - #5

Merged
llmassisted merged 19 commits into
mainfrom
feat/oura-overnight-readings
Oct 7, 2026
Merged

llmassisted merged 19 commits into
mainfrom
feat/oura-overnight-readings

Conversation

@llmassisted

@llmassisted llmassisted commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

What this PR does

Shows the Oura ring's captured overnight SpO2 and skin-temperature readings, and stops excluding PI=255 SpO2 samples.

1. Include PI=255 samples (639e553). PR #4 skipped samples whose perfusion-index byte is 0xFF as "held / no-signal", based on six samples in the 2026-09-27 capture that shared one R (0.766). The 2026-09-28 capture has four PI=255 samples with four distinct R values (0.5829, 0.6310, 0.5941, 0.7169; about 95–98 %), sitting beside normal-PI samples with similar R, which looks like PI saturating on a strong signal. The byte alone no longer justifies exclusion. Every sample now counts, and the nightly line reports pi255=N for further study. The raw records are stored, so the change applies retroactively on the next re-score.

2. Overnight readings card (d0598f8). On the Oura SpO2 and skin-temperature detail screens:

  • date navigation, an interactive chart and a paginated list of every stored reading in the sleep window ending on the chosen date;
  • SpO2 samples are labelled "reading i/n" with their record time, because individual sample times are not known;
  • skin temperature includes readings excluded from the nightly average (the note says so);
  • gated to Oura owners, and for SpO2 to Settings → "Blood Oxygen: strap estimate";
  • paged database reads, so long nights cannot hit a row cap.

3. Review fixes (cc01fae).

  • The 18 strings were in a separate values/oura_overnight.xml, outside the i18n audit (it only reads values*/strings.xml), so the card would have shipped English-only. They are moved into strings.xml and translated for de, es, fr, pl, pt-rPT, ru and zh. The Settings toggle's name stays in English inside each translation because that label is not localized.
  • A full night is ~20,000 SpO2 samples. The chart now draws at most 400 points (chartIndices: each bucket keeps its min and max, so a brief dip or peak survives). The list and the low / high line still use every reading; row labels are built on demand.
  • versionCode 545.

5. Five-minute median chart (8663a70). Replaces the thinned raw-point chart with five-minute intervals:

  • a median line, with each interval's full low–high range shaded unsmoothed behind it, so brief dips stay visible;
  • recording gaps stay gaps, and the median curve cannot overshoot between two medians;
  • tap or drag selects an interval and shows its span, median, range and reading count above the plot;
  • a fixed 85–100 % SpO2 axis.

Every raw reading stays in the paginated list. Three new strings, in all locales. The accompanying androidTest is omitted: it would be the repo's first, and the lockfile does not cover the androidTest classpath (kotlin-stdlib-common:1.9.24 … not part of the dependency lock state).

4. Curated upstream fixes from upstream main. 12 of the 160 new upstream commits, chosen for an Android + Oura Ring 4 user and cherry-picked with -x. All applied without conflicts.

  • Oura beat double-counting, the cause of blank HRV on redrained or overlapping nights:
    • 066f5021: an interrupted drain now banks its resume cursor, so the next connect does not re-serve and re-store what it already stored (upstream issue 2455).
    • c5cfb212: a ring record the drain served twice is collapsed on read (upstream issue 2457).
    • 401b4575: HRV is scored from one R-R beat channel per window, not 0x80 on top of 0x60 (upstream issue 2423).
  • Stress: 52983414 honours the personal baseline on Today (upstream issue 2450); 678147d0 gives the daytime-lens memo a slot per lens (upstream issue 2452).
  • 57698ad3: Deep Timeline x-axis labels and per-minute zoom ticks, with upstream's two follow-ups. 53769e5b makes its Android test compile (it does not compile on its own). 1e8ef1ec keeps the sub-hour ticks off Today's small HR card, behind a deep-zoom flag; without it, Today drew one-minute gridlines.
  • Sleep stages: 0f607ea0, 8a950360 and 74a9f429 align the stage rows and hypnogram read-out with the chart.
  • 4a827f90: collapses overlapping cross-source sleep nights.
  • a7373b34: keeps the Night diagnostic line's rr= counting beats as banked, on both platforms. It also supplies the Swift twin Reads.rawRrIntervals that the R-R channel fix's Kotlin read pairs with.

Parity: RAW_RR_INTERVALS_SQL (Room's compile-time query string; Swift builds the same query inline) gets a platform_specific disposition, and the twin map is refreshed (ratchet errors=0).

Deliberately not taken:

  • 5de05190 (restore the primary-night resting-HR floor) leaves primarySessionRestingHR reached only from tests on both platforms. That new test-only-callsite finding cannot be authorized by a disposition, and deleting the twin pair is a forbidden pair removal. Upstream landed it unchecked and repaired a red main afterwards (069ac57d, stale-base repair). It is deferred to a full upstream merge, where that repair flow applies.
  • WHOOP 4/5/MG fixes (R-R sources, alarms, ECG, bonding), Italian localisation, Apple-only and tooling changes.
  • The bedtime-date edit, Charge-driver verdict, remaining stress and deep-prior commits: they depend on upstream's i18n refactor and earlier stress work. A full upstream merge was trial-resolved (13 conflicting files, all resolvable while keeping this fork's behaviour), but it currently exceeds the analyzeRecentOnCpu bytecode budget (56,072 > 55,700) and would need 32 Italian strings, so it is deferred.

Type of change

  • New feature
  • Bug fix

How it was tested

  • OuraOvernightReadingsTest (8; chart tests now cover interval medians and ranges, kept dips, unbridged gaps, interval hit-testing and recompute-on-change): per-record indices including PI=255, temperatures without the wear gate, edited sleep bounds on late wakes, paging across boundaries, malformed records, and chart thinning (≤ 400 points, ordered, keeps an isolated dip and peak).
  • OuraSpO2RatioTest (6), including the verbatim 04:50 record and its pi255=3 log line.
  • Full local Android suite (fresh run, results cleared first): 6,527 / 6,529 pass (the two known macOS/JBR RecoveryDriversTest float assertions fail).
  • i18n audit (now covering the moved strings), doc-comment lint, and parity ratchet on the PI=255 change (errors=0).
  • Hardware: the card has not yet been exercised on device.

Related issues

Refs #4

🤖 Generated with Claude Code

llmassisted and others added 15 commits September 30, 2026 19:02
Count PI=255 samples in nightly diagnostics instead of excluding them. Update the protocol interpretation and add regression coverage using the September 28 capture.
Add date navigation, an interactive chart, and a paginated list alongside nightly averages. Preserve grouped SpO2 samples and expose stored skin temperatures even when the nightly average is unavailable.
- Move the 18 overnight-reading strings from values/oura_overnight.xml into
  values/strings.xml and translate them for de, es, fr, pl, pt-rPT, ru and
  zh. The separate file sat outside the i18n audit, which only reads
  values*/strings.xml, so the card would have shipped English-only. The
  Settings toggle name is kept in English inside each translation because
  that toggle's label is not localized.
- Draw at most 400 chart points (OuraOvernightReadings.chartIndices): each
  bucket contributes its min and max, so a brief dip or peak survives. A full
  night is ~20,000 SpO2 samples; the paginated list and the low / high line
  still use every reading. Row labels are built on demand instead of for every
  sample, and the axis endpoints use the full series.
- versionCode 542.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…does not restore what it already stored (ryanbr#2455)

Reported in ryanbr#2443 by @pipiche38, with the drain traced through the log and the over-count measured
against the raw capture.

The resume cursor is committed only when a drain ENDS. A link that dropped partway through threw the
drain's progress away: the next connect refetched from the old cursor and the ring re-served
everything the interrupted drain had already stored. Those copies resolve under the NEXT session's
SyncTime anchor, so they land a second or two off the first copy and MISS `rrInterval`'s
(deviceId, ts, rrMs, seq) key instead of colliding with it. The beats are stored twice, the night's
R-R coverage goes above 1, and the night's HRV is refused as an over-count. On the reported night
1,753 of 4,346 records were served twice.

Both teardowns commit now, not just the one the report named. A deliberate `stop()` mid-drain loses
the cursor by exactly the same route as an involuntary drop, and both sites already had the ordering
the commit needs.

Nothing new decides the cursor. `commitInterruptedDrainCursor` calls the existing
`commitResumeCursor(drainCompleted: false)`, so the forward-only rule, the anchor-resolution rule
and the ryanbr#2097 reboot judge all apply unchanged. It commits only mid-drain and only with banked
progress, so a link that drops while merely streaming writes nothing and no cursor rule is asked to
judge a drain that stored nothing.

It never makes the ryanbr#2097 REBOOT judgement, which is the one part of `commitResumeCursor` an
interrupted drain must not reach. That judgement wipes the cursor to 0 and re-pulls the ring's whole
history, and it declines to trust continuity when this session never adopted an anchor, which is the
ordinary state of a drain cut short before a 0x13 reply resolved. Answering "no evidence" with a full
re-pull would double-store everything re-served, which is the defect being fixed here. A genuine
reboot still serves pre-resume data on the next drain, so deferring the judgement to a drain that
finishes costs nothing.

Placement is load-bearing and is what the tests pin: AFTER the hypnogram flush, so a 0x49 burst still
assembling banks against the cursor it belongs to, and BEFORE the driver is torn down, because the
commit asks the driver to resolve the candidate ring time and a stopped driver has no anchor left.
The report raised the first of those as a risk; both teardown sites already flushed immediately above
where the commit goes.

This NARROWS the window rather than closing the hole, which the reporter's own follow-up shows: on a
second ring the duplicates arrived after the night had already drained to bytes_left 0, with no
cursor involved. A redrain of a completed night stores the same beats twice by the same route. The
durable fix is a dedup that survives an anchor shift, which the stored row cannot express today: it
carries ts, rrMs, seq, ord and srcChannel, and no ring time. That stays open on ryanbr#2443, as does the
heal for nights already double-stored.

A BLE source has no unit-testable surface, so the wiring is pinned against the source the way
StressPersonalBaselineSurfaceTest pins its own: both platforms commit at both teardowns, each commit
sits after the hypnogram flush and before the driver teardown, and the helper gates on the drain
phase and on banked progress rather than committing on every disconnect. Removing either call site
reds the count test. Full Android suite 6418 tests, no failures.

Refs ryanbr#2424.

(cherry picked from commit 066f502)
…ight is readable again (ryanbr#2457)

Reported in ryanbr#2443 by @pipiche38 and split out as ryanbr#2456, with both instances measured against raw
captures and backups.

A ring record is served again whenever history is refetched: after a link drops mid-drain, which
ryanbr#2455 now narrows, and also after a night has ALREADY drained, which no resume cursor can prevent.
Every connection adopts its own SyncTime anchor, so the second copy resolves to a wall-clock second
one or two off the first and misses `rrInterval`'s (deviceId, ts, rrMs, seq) key instead of colliding
with it. Both copies are stored, the night's R-R coverage rises above 1, and its HRV is refused as an
over-count. One reported night went from coverage 0.99 with HRV shown on Today to 1.22 with the
night's HRV gone, retroactively, after a redrain added 6,466 channel-3 beats of which 5,471 were an
exact copy stamped one second later.

What identifies a copy is the RECORD, not the beat. A banked 0x60 record stamps all of its intervals
on one timestamp, so a re-served record reappears as the same ordered run of rrMs values, on the same
channel, a second or two later. Single beats are not enough to go on: equal successive beats are
physiological and are deliberately kept (ryanbr#163), and on a clean night 2.5% of beats already have an
equal neighbour one second away. A whole ordered run repeating is not a coincidence at that rate.

Collapsed on READ, not deleted on write, and that is the point of doing it this way:

  * it needs no migration, on a table holding real health history
  * it costs nothing when the ring never re-serves, which is the ordinary case
  * it repairs nights ALREADY double-stored, which a write-side key can never do

It does not reclaim the disk those rows occupy, and it is a recognition rather than an identity. The
structural fix is still to dedup on a clock the ring owns, which the stored row cannot express today:
it carries ts, rrMs, seq, ord and srcChannel, and no ring time. ryanbr#2456 stays open for that.

Placed at the per-device read on both platforms, so every SCORING reader agrees rather than one
scorer: the damage shows up as a coverage over-count and coverage is computed from this read. Only
Oura channels are considered; a WHOOP transport is never touched. Both platforms order these rows
ORDER BY ts, ord, rrMs, seq, so the run signature is the same on each side.

The raw diagnostic exports deliberately do NOT collapse and still show both copies, on both
platforms. That is what a raw export is for, and it is the evidence this was diagnosed from. Two
consequences worth stating: a diagnostic export and the app can legitimately disagree on beat counts,
and the `hrv diag` line will now print the collapsed count, which is the number actually scored.

Twelve cases each side, the same cases, and they RUN: WhoopProtocol has no GRDB dependency so its
tests execute on Linux. A record served again at +1s and at +2s collapses; the same run further out is
kept; three copies collapse to one and not to two; a flat run of equal beats is kept; short
coincidental repeats are kept; WHOOP channels, untagged rows and reordered runs are all kept; order
and content of what survives are unchanged; a clean 600-second night is returned identical.

Mutation-tested three ways. Dropping the minimum run length to 1 reds six cases, removing the
Oura-only guard reds exactly the WHOOP case, and widening the window to nine seconds reds exactly the
distant-record case.

Android suite 6430 tests, WhoopProtocol 803, no failures. Parity ledger clean.

Refs ryanbr#2443, ryanbr#2455.

(cherry picked from commit c5cfb21)
…0x60 (ryanbr#2423)

An Oura ring measures the same heartbeats on more than one tag and banks every one of them. The
scoring read excluded only 0x6E (ryanbr#1071) and kept both remaining beat channels, so a night could be
read as roughly two overlapping copies of itself: on one ring 0x60 alone covers 0.91-0.99 of the wall
clock over 10 of 11 nights, while 0x60 plus 0x80 reads 1.01-1.31. The ryanbr#1118 coverage gate then
refused whichever nights happened to bank more 0x80, HRV went blank, and Charge calibration stalled
at "3 of 4 nights" for days, for reasons that had nothing to do with those nights.

Neither channel is a duplicate to exclude by name. Which one is complete is a property of the
capture, not of the tag, so the read keeps green alone when it holds MORE beats in the requested
window than the amplitude family, and the amplitude family alone otherwise, ties included. 0x60 and
0x44 are counted and kept together: one decoder, split for labelling only, so they are never scored
against each other. A ring with only one of them keeps it, which is the guarantee the old exclusion
was protecting. NULL rows and every non-Oura code pass untouched, so WHOOP 4, WHOOP 5 and legacy rows
read exactly as before.

An uncorrelated subquery over the same time and suspect predicates as the outer read, evaluated
before LIMIT, the same shape as the WHOOP 5 transport selection beside it. Rows are filtered, never
deleted, and no migration is involved: the other channel stays on disk as its cross-check.

Verified against two real backups over the window the app actually reads, 30 h before to 6 h after
each main session. Six refused nights on the reporting ring become five passing and one still
refused; four refused nights on a second ring now pass; no night that passed before regresses on
either. The nights still refused are 0x60 over-covering on its own, which is record overlap and a
separate problem this does not claim to answer.

One interaction worth recording, because the two changes were built in parallel and meet here for the
first time. ryanbr#2457 collapses a ring record the drain served twice, and it does so AFTER this read's
SQL. So the "which channel is fuller" decision is made on beats that still include re-served
duplicates, and a redrain inflates channels unequally: the ryanbr#2443 capture shows one channel gaining
22% and another 7.6%. In these captures the margin is far too large to flip, 36,317 beats against
2,006, but the count the choice rests on is not exactly the count that ends up scored, and that is
worth knowing before someone reads it as a contradiction.

Five new Swift tests: the fuller channel wins, green wins when green is the fuller one, the choice is
per requested window, a tie goes to the amplitude family, and 0x60 plus 0x44 count and survive as
one. Kotlin runs the production SQL on real SQLite using the Swift fixtures and expected values
verbatim, so the two cannot drift into agreeing in prose while differing in fact.

Authored by @pipiche38.

Refs ryanbr#2424, ryanbr#1071, ryanbr#1118.

(cherry picked from commit 401b457)
Reported in ryanbr#2430 by @bartmuskala: the personal stress baseline applied on the Stress detail screen
but not on Today, so the two surfaces disagreed about the same day.

One foreground resolver per platform now answers the scoring-mode question for both surfaces:
`DaytimeStressMode.selected` on Apple, `selectedDaytimeStressMode` on Android. With the lens off it
returns immediately and the read is byte-identical to before; with it on it reduces each of the
trailing 30 days to two Doubles rather than holding thirty days of raw HR and R-R at once (ryanbr#2107).
Resolution sits inside the existing HR-count guard on both platforms, so a day with nothing to score
never pays for it.

The scoring lens is part of the memo identity on both producers. Foreground Today and the background
publishers can call in either order, and without the lens in the key an unchanged HR fingerprint
would replay one surface's curve into the other.

Android Today no longer seeds its card from the widget snapshot while the personal lens is on. That
snapshot is the default-lens curve, so using it as a cold-start seed recreated the reported
divergence until the foreground pass finished, or indefinitely with no active strap id.

Background publishers deliberately stay on the cheaper default lens on both platforms: a display
preference must not turn an unprompted periodic publish into thirty days of raw reads. All four
Android callers and all three Apple callers were checked against that rule; AppViewModel's is the
widget-snapshot writer and correctly stays default.

Source tripwires in StrandTests and the Android suite pin the wiring at each call site, which is the
failure this fixes: every one of those sites compiles while silently omitting the preference.

Two follow-ups are already identified and are not in this change. The producer memo holds ONE slot,
so with the lens on a background publish between two Today ticks makes every tick miss regardless of
the HR fingerprint, and Android's Today re-ticks every 15 minutes; a slot per lens restores the
fingerprint gate. And the Apple resolver derives each past day by adding days to the start of today
without re-anchoring, which in a zone whose clocks shift at midnight leaves all thirty windows an
hour out on the transition date, where the Kotlin twin's LocalDate arithmetic is immune. Both
toggles default off, so neither reaches a user who has not opted in.

Authored by @kavemang.

(cherry picked from commit 5298341)
…ple resolver to real local days (ryanbr#2452)

Two follow-ups to ryanbr#2450, both found reviewing it and neither in scope for that change.

ONE SLOT PER LENS. ryanbr#2450 put the scoring lens into the memo's identity so an unchanged heart-rate
fingerprint could not replay one surface's curve into the other. That was right and, with a single
slot, self-defeating: three Android callers and one Apple caller ask with the default lens while
Today asks with the selected one, so with the toggle on each call evicted the other's entry and
every call missed whatever the fingerprint said. Android's Today re-asks every 15 minutes, so the
trailing-history read the fingerprint gate exists to avoid was being paid on essentially every tick.
Keyed by lens, each surface keeps its own gate. Two entries at most.

The Kotlin slots stay VOLATILE, which the single slot already was. Four callers reach that producer,
on a BLE connection service, the widget worker, the view model and a Compose effect, so they can be
inside it at once; a plain mutable map there would be a data race on the table itself, not merely a
lost entry. It is an immutable map republished behind `@Volatile`, so a write copies two references
on the rare pass where the fingerprint moved. The Apple memo is `@MainActor`, so a dictionary is
already safe there.

REAL LOCAL DAYS. The Apple resolver derived each trailing day by adding days to the start of today
and never re-anchored. A day-add preserves the TIME of day, so in a zone whose clocks shift at
midnight the transition date's start-of-day is 01:00 and every window walked back from it ran 01:00
to 01:00, taking an hour of the next day and dropping its own first hour. Across two years of
America/Santiago windows that is 60 of 21900 starting off their day and 118 that are not one local
day long. Europe/London, America/New_York, Australia/Sydney and Asia/Tokyo are clean before and
after: a day-add from a midnight that EXISTS already yields the next midnight, so this is confined
to zones that shift at midnight. The Kotlin twin walks LocalDate and was never affected, which is
the point: the resolver ryanbr#2450 made shared should not differ between platforms.

The tripwires ryanbr#2450 added asserted the comparison, which is the mechanism this replaces rather than
the invariant behind it. Repinned to the keying, with one more holding the volatile publication that
the compiler only caught here by accident.

Both test-reset helpers drop every slot, not the one field that is gone. The Kotlin one was caught
by a local sweep for surviving references; the Apple one was not, because the sweep was run on one
platform and not the other, and the app targets do not build on this host. CI caught it.

Full Android suite 6414 tests, no failures.

Refs ryanbr#2430

(cherry picked from commit 678147d)
…imeline on Android and iOS/macOS

(cherry picked from commit 57698ad)
Present stage breakdown and timeline rows as Awake, REM, Light, Deep on Android and Apple. Keep stage values, percentages, and colours attached to their original labels.

Refs ryanbr#2534

(cherry picked from commit 0f607ea)
Stacked on @kavemang's change, which put Sleep detail and the Android surfaces into chart-depth order.

Apple writes the stage sequence out inline rather than routing through a shared view, and it does so in
TWO files: `SleepView` for Sleep detail and `StagesCard` for the hosted card that `LiquidTodayView`
renders. Liquid Today is the default Today, so leaving `StagesCard` alone would have made Today and Sleep
detail disagree about stage order, which is the exact complaint in ryanbr#2534. Both of its row stacks and the
doc line that still described the old sequence are brought across.

Android needed nothing further: every screen there goes through `StageBreakdownRows` and `StageTimeline`.

The order is written out six times across four files, which is how it diverged. Before today the two
Apple files disagreed with each other AND each disagreed internally between its breakdown rows and its
timeline rows, so the old "WHOOP order" comment was not being honoured by any single surface. A source
tripwire now pins all six and asserts they are identical. It compares the EXTRACTED sequence, not a
substring, because a test checking only "awake comes before deep" would have passed the reported state.

The tripwire deliberately claims only the six row stacks. Two other places name the stages in a sequence
and are left alone, with a scope note in the test saying so rather than implying coverage:
`XiaomiBandView` lists REM / Deep / Light / Awake for a Mi Band night, and `Charts.hypnogramSummary`
builds the hypnogram screen-reader read-out as deep / REM / light / awake, so a TalkBack user hears a
different order from the one they see. Both are worth a look on their own terms.

(cherry picked from commit 8a95036)
The hypnogram's screen-reader summary announced deep first while every row stack draws awake first, so a
TalkBack user heard a different order from the one on screen. It now speaks awake, REM, light, deep.

Pulling on that turned up a second and quieter problem. The read-out fed its OWN stage order into
`wholePercentages`, which breaks ties by lower index, so the input order decides which stage receives the
spare percentage point. On a night where two stages share a fractional remainder the read-out therefore
announced a different number than the row on screen for that same stage. With awake 50, light 205, deep 95
and REM 50, the rows say awake 13 and REM 12 while the read-out said awake 12 and REM 13.

Display order and apportionment order are separate concerns, so they are now separate lists: `spoken`
matches the rows, and `apportion` stays the order every visible surface already uses. There is a comment
saying that merging the two back into one list would look tidier and would silently bring the mismatch
back, because that is exactly the tidy-up someone would reach for.

`hypnogramSummary` becomes internal so this can be pinned at all; it was private and untested. The tie
test compares against `stageSharePercent` itself rather than a hardcoded number, so the two cannot drift
apart without it failing.

(cherry picked from commit 74a9f42)
…latforms (ryanbr#2435)

The one-Oura-channel selection (ryanbr#2423) changed what `rr=` counts in the strap log's
`Night …: grav=N hr=N rr=N resp=N skin=N` line, from beats BANKED to beats scored, on both platforms:
Swift `DebugDataDiagnostics` read `WhoopStore.rrIntervals`, and Kotlin `AndroidDiagnostics` read
`rrIntervalsForDevice`, which routes to the same selecting SQL. The two platforms still agreed with
each other and both differed from every log filed before.

`WhoopStore.rawRrIntervals` is added as the twin of Kotlin `WhoopDao.rawRrIntervals`
(`RAW_RR_INTERVALS_SQL`): the 0x6E duplicate and quarantined beats excluded, no scoring selection, the
same ORDER BY. Both Night lines point at it. Scoring is untouched, every scoring reader still goes
through `rrIntervals`, and the Kotlin function stops being one-sided in the parity ledger.

Two consequences are documented where the number is produced rather than left to be discovered.

A WHOOP 5's `rr=` does not go back to what it was; it steps UP. The strict single-transport selection
predates ryanbr#2423, so a 5/MG's `rr=` was already the scored transport in every older log, and the raw
read drops that too. That is the right answer for a line whose job is to say what the strap banked,
since a legacy row that cannot be spliced into a scored beat train was still banked, and one number
meaning "banked on a ring, scored on a strap" would be worse. It is still a change, on a device this
work otherwise leaves alone.

The raw read also does not collapse a record the drain served twice, where `rrIntervals` now does
(ryanbr#2456). On a redrained night `rr=` counts the duplicates and the `hrv diag` line does not, so the two
disagreeing is the clearest signal that a redrain happened at all, rather than a fault.

Tested on the same fixture and expected values as the Kotlin
`OuraOneChannelReadSqliteTest.theRawExportReadKeepsBothBeatChannels`, so the two reads cannot drift
into agreeing in prose while differing in fact.

Authored by @pipiche38.

Refs ryanbr#2424, ryanbr#2423.
- versionCode 543.
- platform_specific disposition for WhoopDao.RAW_RR_INTERVALS_SQL (Room's
  compile-time query string; the Swift twin builds the same query inline),
  brought in by the cherry-picked upstream R-R channel fix (401b457).
- Twin map refreshed (ratchet errors=0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@llmassisted llmassisted changed the title feat(oura): overnight SpO2/temperature readings; include PI=255 samples feat(oura): overnight readings, PI=255 inclusion, curated upstream fixes Oct 1, 2026
andigandhi and others added 4 commits September 30, 2026 21:01
(cherry picked from commit 53769e5)
chartTimeTicks is shared. The Deep Timeline passes its pinch-zoom window, but
Today's HR card passes the RENDERED extent of its banked buckets rather than a
nominal window, so adding tiers below an hour reached Today as well: a morning
holding ten minutes of HR drew ten 1-minute dotted gridlines on a small card.
Labels are thinned by an overlap skip; the gridlines are not.

The sub-hour tiers now sit behind deepZoom, off by default and set only by the
surface that actually zooms. That also makes the retained comment true again,
the one about thresholds sitting below the nominal Today windows so a window
covering slightly less than nominal still lands on its intended interval.

Swift keys that on zoomBounds rather than zoomDomain, for the reason
OverviewHRChart already records twice: zoomBounds is set once by the zooming
call sites, while zoomDomain stays nil until the first pinch, so keying on it
would hand a not-yet-zoomed Deep Timeline the static tiers and then switch them
under the user (ryanbr#829).

The Swift function now returns dates and no longer builds a DateFormatter per
call. Every label it formatted was discarded by the caller's map, once per body
evaluation, which during a pinch is once per frame. Its doc no longer claims the
two platforms read identically: positions are shared, labels are not, because
Swift draws them with AxisValueLabel() in the viewer's locale where the Kotlin
side formats HH:mm itself.

Tests: the three deep-zoom tiers now pass the flag, and both platforms pin the
Today guarantee. ChartTimeTicksTests is new, because eight thresholds written
twice in two languages drift, and a drift between them is invisible in a build.

(cherry picked from commit 1e8ef1e)
APK 543 predated the upstream Deep Timeline follow-ups (53769e5, 1e8ef1e);
544 includes them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…readings

Replace the thinned raw-point chart on the overnight SpO2 / skin-temperature
card with five-minute intervals: a median line plus each interval's full
low-high range drawn unsmoothed behind it, so a brief dip stays visible.
Recording gaps stay gaps (no line across an empty interval); the median curve
cannot overshoot between two medians. Tap or drag selects an interval and shows
its span, median, range and reading count above the plot. SpO2 uses a fixed
85-100 % axis so nights compare at a glance. Every raw reading stays in the
paginated list and the nightly statistics; display aggregation never mutates
it. Notes and three new strings in all shipped locales.

The device UI test that came with this rework is not included: it would be
the repo's first androidTest, and the dependency lockfile does not cover the
androidTest classpath, so it cannot be built. The layout property it checked
(readout laid out above the plot) holds by construction.

versionCode 545.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@llmassisted
llmassisted merged commit 56380f7 into main Oct 7, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants