Skip to content

Calibrating card: say WHY the countdown restarted (#731)#788

Merged
ryanbr merged 3 commits into
mainfrom
fix/calibrating-restart-cause-731
Jul 25, 2026
Merged

Calibrating card: say WHY the countdown restarted (#731)#788
ryanbr merged 3 commits into
mainfrom
fix/calibrating-restart-cause-731

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 24, 2026

Copy link
Copy Markdown
Owner

The UX half of #731. The count was never the missing information.

Both platforms already show it — Swift calibrationProgress ("Calibrating, 3 of 4 nights") / calibrationCountdown ("1 night to go"), Kotlin ScoreState.Calibrating.detail. The reporter saw that, with 15 valid HRV nights on file, and tapped Recalibrate baseline again — which discards every earlier night and resets the count to 0. Two weeks of that and Charge could never come back.

Nothing told them their own tap caused the restart, so re-tapping was the natural move. That's the loop this closes.

Change

calibrationRestartCause(recalibratedOn:) on both platforms:

Restarted when you recalibrated on 19 Jul — no need to tap it again.

  • A separate whole sentence, not a fragment stitched onto the countdown, so translators never see a partial string.
  • Returns nil/null when no recalibration epoch is set → the card is byte-identical for every user who never tapped it.
  • Pairs with diag: report the HRV baseline recalibration epoch + dropped nights (#731) #786, which supplies the epoch day (epochDropDiagnostic) and puts the same fact in the strap log.

Parity

Same function, same sentence, both platforms. One honest asymmetry: the Apple string is localized (de/es/fr/pt-PT added, %@ specifier parity verified), while the Kotlin twin is a plain literal — matching its immediate neighbour in TodayScoring.detail, which is also a literal. Extracting that whole block to resources is a separate cleanup, not something to do halfway here.

Verification

  • Diff-scoped i18n gate re-run locally: exit 0 — no new hardcoded literals, no catalog gaps, specifier parity OK. (Checking this locally is the lesson from Support custom AI gateway auth #766, where a stale green check hid pt-PT gaps.)
  • .xcstrings change is purely additive (+34/−0), no reformat.
  • Pure copy helpers, no behaviour change; +68/−0 overall.
  • ⚠️ App-target, so not compiled here.

Deliberately NOT included

The card wiring that calls these helpers. Both are pure and ready, but choosing where the line renders (hero vs supporting line, both platforms' layouts) is a design call better made with the UI in front of you — and it's app-target code I can't compile or see. Say where you want it and I'll wire it.

Also still open, and arguably the bigger guard: a confirmation on the Recalibrate button itself ("This discards your baseline and starts over — 4 nights"). Right now it's a silent destructive reset presented as a fix. That's a product decision, so I left it out.

ryanbr added 3 commits July 24, 2026 16:01
The count was never the missing information. A reporter sat at 'Calibrating,
3 of 4 nights' with 15 valid HRV nights on file and tapped 'Recalibrate
baseline' again - which discards every earlier night and resets the count to
0. Two weeks of that and Charge could never come back. Both platforms already
show the count (Swift calibrationProgress / calibrationCountdown, Kotlin
ScoreState.Calibrating.detail); neither said the user's own tap caused the
restart, so re-tapping was the natural move.

Add calibrationRestartCause(recalibratedOn:) on both platforms - a separate
whole sentence, not a fragment stitched onto the countdown, so translators
never see a partial string. Returns nil/null when no recalibration epoch is
set, so the card is byte-identical for every user who never tapped it.

Localized on Apple (de/es/fr/pt-PT, %@ specifier parity verified); the Kotlin
twin matches its neighbouring copy in TodayScoring, which is a plain literal.
Diff-scoped i18n gate re-run locally: exit 0, no new hardcoded literals, no
catalog gaps.

Pure copy helpers, no behaviour change. App-target, so not compiled here -
and the CARD WIRING that calls these is deliberately not included (see PR).
…s) (#731)

Makes the #788 helpers actually visible - unwired they were dead code.

iOS: both calibrating cards (TodayView.chargeCalibrationCountdown and
CoupledView.calibrationCard, deliberately identical) render the line under
the existing progress copy, via currentCalibrationRestartCause() so the
single UserDefaults read lives in one place.

Android: ScoreStateNote gains an optional restartCause param (default null,
so every existing caller is unchanged) rendered under state.detail; the
calibrating call site passes it, reading the real pref
NoopPrefs.of(context).getLong(Baselines.hrvBaselineEpochKey, 0L).

Added recalibrationDay(epoch:) on both platforms to format the epoch as a
short day. Verified by compiling and RUNNING the Kotlin functions standalone:
epoch<=0 -> null, null/empty day -> null cause, and the day formats as
expected (the first run returned '18 Jul' for a UTC-midnight epoch on a
PDT host, which is CORRECT - the user tapped on their local date, and Swift
formats in local time too; the test assumption was wrong, not the code).

Note the deliberate split: the DISPLAY day is local (the day the user tapped),
while #786's drop comparison is UTC (it mirrors foldHistory's UTC compare).

Two things I could NOT do: the a11y label was left alone - concatenating onto
accessibilityLabel("literal") silently switches it from the LocalizedStringKey
overload to the String one and un-localizes it, so VoiceOver users get the
visible line's info only via the card text. And none of this is compiled.
Self-review catch. Both the CarriedLastNight and Calibrating cards call
ScoreStateNote with identical indentation, so my single-occurrence replace hit
the first one - putting 'Restarted when you recalibrated on ...' on the
carried-sleep card, where it is irrelevant, and leaving the calibrating card
(the only place it means anything) without it.

Revert the CarriedLastNight site to the plain call and patch the Calibrating
site instead, anchored on its 'scoreState is ScoreState.Calibrating' guard so
it cannot land on the wrong card again.
@ryanbr
ryanbr merged commit 4838276 into main Jul 25, 2026
1 check passed
ryanbr added a commit that referenced this pull request Jul 25, 2026
MARKETING_VERSION/versionName move together per the versioning rule; build numbers
increment independently (207->208 Apple, 298->299 Android).

The staging workflow reads MARKETING_VERSION for its title and asset names, so main
has to carry 9.1.1 for the rolling testing-latest release to be labelled as such —
the previous 9.1.1 staging build was cut from a commit that never landed on main.

This is also the first compile of the app-target changes merged today (#783/#784/#785,
#787, #788, #793, #795, #519/#798, #523, #778), which CI does not cover: app-build.yml
and android.yml are disabled and only the Swift packages and the i18n gate run on PRs.
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