Calibrating card: say WHY the countdown restarted (#731)#788
Merged
Conversation
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
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.
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.
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"), KotlinScoreState.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:nil/nullwhen no recalibration epoch is set → the card is byte-identical for every user who never tapped it.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 inTodayScoring.detail, which is also a literal. Extracting that whole block to resources is a separate cleanup, not something to do halfway here.Verification
.xcstringschange is purely additive (+34/−0), no reformat.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.