Skip to content

fix(today): say what the heart-rate read returned, not why - #2749

Merged
ryanbr merged 1 commit into
mainfrom
today-hr-empty-copy
Oct 9, 2026
Merged

ryanbr merged 1 commit into
mainfrom
today-hr-empty-copy

Conversation

@ryanbr

@ryanbr ryanbr commented Oct 9, 2026

Copy link
Copy Markdown
Owner

The problem

The Today heart-rate card's empty state said:

Calibrating, no heart rate banked yet today. Your curve fills in as the strap offloads.

The branch that renders it checks one thing: whether the active-strap-plus-imports union came back with fewer than two 5-minute buckets for the window. It does not know whether a strap is calibrating. It does not know whether a strap has offloaded. It does not know whether rows are banked under a source the union cannot reach. It asserted all three.

That matters because the card is the first thing a reader consults when a curve is missing, and naming an unchecked cause sends them to the strap when the answer may be somewhere else entirely.

The same line also fired on a day holding exactly one bucket, where "no heart rate" is simply false.

The change

The state splits in two and describes the stored rows:

day's buckets line
0 No heart rate stored for today yet. The curve draws once the strap offloads, or once an import brings one in.
1 Only one five-minute block of heart rate is stored for today. The curve needs two of them to draw a line.

The past-day and narrow-window lines are unchanged.

The split reads the day's bucket count, never the windowed subset, because both new lines speak about today. A rolling 1h window that happens to exclude the one stored block must not make the card claim the day holds nothing, which would be the same over-claim in a new place. That rule has its own test.

Android's selection is extracted into a pure hrEmptyMessageRes(isToday, windowIsWholeDay, dayBucketCount) so it can be pinned without a Compose host. Apple's lives in hrEmptyTitle / hrEmptyDetail, hoisted out of the view body rather than nested as ternaries, since TodayView already sits near the type-check budget.

Localisation

today_hr_calibrating is replaced by today_hr_none_today and today_hr_one_block across the default resources and all eight Android locale files, so nothing is left behind to trip ExtraTranslation. Four new keys land in Strand/Resources/Localizable.xcstrings with all nine non-English locales filled, inserted in place rather than through a rewrite, so the catalog diff is 232 added lines and nothing touched.

Verification

  • :app:compileFullDebugKotlin and :app:compileFullDebugUnitTestKotlin clean
  • HrEmptyMessageTest, six cases: the past-day line regardless of count, the 0 and 1 cases, the window case, and the two that pin the day-count rule against a narrow window
  • Source tripwires in the same test for the Apple twin and for the retired resource key, since neither has JVM reach of its own
  • Tools/doc_comment_lint.py clean
  • Tools/i18n_audit.py --ci origin/main clean, with no new focus-locale gaps on either platform

What this does not do

It does not make the card able to tell a strap that has never offloaded apart from rows banked where the union cannot see them. That needs a diagnostic the card does not have, and inventing one here would repeat the mistake. What it does is stop the card from guessing.

Refs #863

The Today HR card's empty state opened "Calibrating" and asserted "no heart
rate banked yet today", then offered "your curve fills in as the strap
offloads". None of that is established on that branch. The only thing the
card knows is that the active-strap-plus-imports union came back with fewer
than two 5-minute buckets for the window. It does not know whether a strap is
calibrating, whether it has offloaded, or whether rows are banked under a
source the union cannot reach, and naming an unchecked cause sends a reader
looking in the wrong place.

The same line also fired on a day holding exactly one bucket, where "no heart
rate" is simply false.

So the state now splits in two and describes the stored rows:

  0 buckets  No heart rate stored for today yet. The curve draws once the
             strap offloads, or once an import brings one in.
  1 bucket   Only one five-minute block of heart rate is stored for today.
             The curve needs two of them to draw a line.

Both platforms, all nine locales. The split reads the DAY's bucket count
rather than the windowed subset, because both lines speak about today: a
narrow rolling window that happens to exclude the one stored block must not
make the card claim the day holds nothing.

Android's selection is extracted into a pure hrEmptyMessageRes so it can be
pinned without a Compose host; Apple's is hoisted into hrEmptyTitle and
hrEmptyDetail rather than nested as ternaries, since TodayView already sits
near the type-check budget. HrEmptyMessageTest covers all six selections and
carries source tripwires for the Apple twin, the retired resource key and the
call site, none of which a JVM test can otherwise reach.

Refs #863
@ryanbr
ryanbr merged commit e580175 into main Oct 9, 2026
6 checks passed
@ryanbr
ryanbr deleted the today-hr-empty-copy branch October 9, 2026 21:22
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.

1 participant