Skip to content

Workouts (iOS/macOS): read HR under the recording strap, not the active id (#512 twin) - #513

Merged
ryanbr merged 1 commit into
mainfrom
fix/workout-hr-device-key-ios-510
Jul 16, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/workout-hr-device-key-ios-510

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 16, 2026

Copy link
Copy Markdown
Owner

What

Swift/iOS+macOS twin of #512. reconcileWorkoutHrWithTrace (the display-time Avg-HR/Effort reconcile) read every workout's HR window under the Repository's single active deviceId. A bout auto-detected on a second WHOOP — its hrSample rows banked under that strap's id — therefore read the active strap's empty window (samples.count < minSamples), so its Avg HR went un-reconciled from the trace and a null Effort wasn't recomputed. Single-WHOOP installs matched the active id, so only multi-WHOOP users were affected.

Fix

New nonisolated static Repository.workoutHrDeviceId(source:activeStrapId:) resolves the read key per row:

  • Detected rows: their own source is the computed strap id ("<base>-noop"), so strip the suffix and read HR under the raw "<base>". A bout detected on a 2nd WHOOP now reads its own strap instead of the active one's empty window.
  • Manual / imported rows carry no strap id in source, so they reconcile against the active strap as before.

Phase 2 of the reconcile computes the key on the main actor (via WorkoutSource.classify) and captures only the resulting Sendable String into each withTaskGroup child — no new actor-crossing.

Why it's safe

  • Single-device: byte-identical. A detected "my-whoop-noop" strips to "my-whoop" == the old active deviceId; manual/imported unchanged.
  • Display-only projection, never persisted (the reconcile is recomputed on every load) — worst case for a multi-strap user is a transient on-screen value, never a bad write.

Parity note (vs the Kotlin #512 twin)

Kotlin keys manual rows on their stored deviceId too; the Swift read-model WorkoutRow (WhoopStore, Codable) carries no deviceId, so a manual session created on a non-active strap reconciles here against the active strap — a minor, display-only divergence. The detected-dominant multi-WHOOP bug is fixed on both platforms; closing the manual-row gap would mean threading the source id through the workout fetch/dedup, deferred as not worth the risk to the single-device path.

Test / verification

WorkoutHrDeviceKeyTests (StrandTests) mirrors the Kotlin WorkoutHrDeviceKeyTest. App-target compile (macOS + iOS) verified via an on-demand app-build.yml run (app-target Swift has no default CI).

…ve id

Swift twin of #512. reconcileWorkoutHrWithTrace read every workout's HR window
under the Repository's single active deviceId, so a bout auto-detected on a
SECOND WHOOP (its hrSample rows banked under that strap's id) read the active
strap's empty window and its Avg HR / Effort went un-reconciled. Single-WHOOP
installs matched the active id, so only multi-WHOOP users were affected.

Resolve the read key per row via the new nonisolated static
Repository.workoutHrDeviceId(source:activeStrapId:): a DETECTED row's own
`source` IS its computed strap id ("<base>-noop"), so strip the suffix to read
HR under the raw "<base>". MANUAL and IMPORTED rows carry no strap id in
`source`, so they reconcile against the active strap as before (single-device:
"<base>-noop" strips to the active id, so the read is byte-identical).

Display-only projection (never persisted). New WorkoutHrDeviceKeyTests pins the
resolution. Parity note: the Kotlin twin also keys MANUAL rows on their stored
deviceId; the Swift WorkoutRow read-model carries no deviceId, so a manual
session created on a non-active strap reconciles here against the active strap
— a minor, display-only divergence; the detected-dominant multi-WHOOP bug is
fixed on both platforms.
ryanbr added a commit that referenced this pull request Jul 16, 2026
…#514)

The 9.0.1 release generated its AppChangelog title as a raw Kotlin literal
(title = "German, French & Spanish, ..."), unlike every other entry which
uses uiString(R.string.l10n_app_changelog_...). The full-tree i18n audit flags
the raw literal, and since i18n-coverage runs only on pull_request (never on
the release's push to main), it landed unaudited and now red-checks every open
PR (#507, #512, #513) on a line none of them touched.

Localize it like the prior entries: title -> uiString(R.string.l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2),
with the string added to values/ (en) + the de/es/fr focus locales. Key follows
the established scheme (first-6-word slug + sha1(title)[:8]; verified it
reproduces the 9.0.0/8.7.0/8.6.2 keys).

i18n_audit.py --ci: OK no hardcoded literals. compileFullDebugKotlin: OK.
Apple side is unaffected (SwiftUI auto-extracts literals).
@ryanbr
ryanbr merged commit 6a285e2 into main Jul 16, 2026
3 of 4 checks passed
@ryanbr
ryanbr deleted the fix/workout-hr-device-key-ios-510 branch July 16, 2026 11:11
ryanbr added a commit that referenced this pull request Jul 17, 2026
Numbers are main's current 292/203 +1, matching the previous staging commit's
pattern, so the .staging app upgrades cleanly over the last testing build (290)
and stays above main's released high-water.

Carries everything merged since the 9.0.1 release:
 - Workouts read HR under the recording strap, not a hardcoded id (#512 Kotlin,
   #513 Swift twin)
 - Oura 0x60/0x80 IBI decoders corrected to the ring's real byte layout (#511)
 - Whoop5Config enable_sig12 corrected to ASCII '1' (#522)
 - Sleep time edits save as one validated window (#525)
 - "Recompute this night" for deleted sleep windows (#526)
 - Stress marker values stay readable (#507)
 - Faster history sync + faster Bluetooth link, both EXPERIMENTAL and default-off
   (#536/#537/#538) - the whole point of this build is field data on those
 - i18n changelog-title fix + main-push audit (#514/#535)

versionName stays 9.0.2 here for the staging identity only; the real 9.0.2
release still bumps main separately (and must be raised past 293 first).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ryanbr#514)

The 9.0.1 release generated its AppChangelog title as a raw Kotlin literal
(title = "German, French & Spanish, ..."), unlike every other entry which
uses uiString(R.string.l10n_app_changelog_...). The full-tree i18n audit flags
the raw literal, and since i18n-coverage runs only on pull_request (never on
the release's push to main), it landed unaudited and now red-checks every open
PR (ryanbr#507, ryanbr#512, ryanbr#513) on a line none of them touched.

Localize it like the prior entries: title -> uiString(R.string.l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2),
with the string added to values/ (en) + the de/es/fr focus locales. Key follows
the established scheme (first-6-word slug + sha1(title)[:8]; verified it
reproduces the 9.0.0/8.7.0/8.6.2 keys).

i18n_audit.py --ci: OK no hardcoded literals. compileFullDebugKotlin: OK.
Apple side is unaffected (SwiftUI auto-extracts literals).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ve id (ryanbr#513)

Swift twin of ryanbr#512. reconcileWorkoutHrWithTrace read every workout's HR window
under the Repository's single active deviceId, so a bout auto-detected on a
SECOND WHOOP (its hrSample rows banked under that strap's id) read the active
strap's empty window and its Avg HR / Effort went un-reconciled. Single-WHOOP
installs matched the active id, so only multi-WHOOP users were affected.

Resolve the read key per row via the new nonisolated static
Repository.workoutHrDeviceId(source:activeStrapId:): a DETECTED row's own
`source` IS its computed strap id ("<base>-noop"), so strip the suffix to read
HR under the raw "<base>". MANUAL and IMPORTED rows carry no strap id in
`source`, so they reconcile against the active strap as before (single-device:
"<base>-noop" strips to the active id, so the read is byte-identical).

Display-only projection (never persisted). New WorkoutHrDeviceKeyTests pins the
resolution. Parity note: the Kotlin twin also keys MANUAL rows on their stored
deviceId; the Swift WorkoutRow read-model carries no deviceId, so a manual
session created on a non-active strap reconciles here against the active strap
— a minor, display-only divergence; the detected-dominant multi-WHOOP bug is
fixed on both platforms.
ryanbr added a commit that referenced this pull request Jul 24, 2026
…ed my-whoop (#767, #717) (#777)

Workout auto-detection silently stopped for live-BLE users while sleep,
charge and everything else kept syncing. Root cause: AutoWorkoutNudge read
HR via repo.hrSamples("my-whoop", ...) — a hardcoded id — but a live strap
banks its raw under its OWN fresh id ("whoop-<uuid>", #908), so the read
returned nothing and detect() bailed at 'hr.size < 2'. The mid-July
active-strap-id migration (#175/#512/#513) moved sleep/charge/health/iOS
reads to the active-strap union but missed this one.

Fix: read the hrSamplesUnion(activeDeviceId) — importedSourceIds(active) ∪
my-whoop, deduped — exactly what every other Today read and iOS
Repository.autoDetectCandidate() already use. Android-only; iOS was never
affected (it already reads the union). No behaviour change for single-WHOOP
installs, whose active id resolves to my-whoop (one id, identical read).

App-target Android; not compiled here (aapt2 x86-only on this host) - run
./gradlew compileFullDebugKotlin before merge.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ryanbr#514)

The 9.0.1 release generated its AppChangelog title as a raw Kotlin literal
(title = "German, French & Spanish, ..."), unlike every other entry which
uses uiString(R.string.l10n_app_changelog_...). The full-tree i18n audit flags
the raw literal, and since i18n-coverage runs only on pull_request (never on
the release's push to main), it landed unaudited and now red-checks every open
PR (ryanbr#507, ryanbr#512, ryanbr#513) on a line none of them touched.

Localize it like the prior entries: title -> uiString(R.string.l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2),
with the string added to values/ (en) + the de/es/fr focus locales. Key follows
the established scheme (first-6-word slug + sha1(title)[:8]; verified it
reproduces the 9.0.0/8.7.0/8.6.2 keys).

i18n_audit.py --ci: OK no hardcoded literals. compileFullDebugKotlin: OK.
Apple side is unaffected (SwiftUI auto-extracts literals).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ve id (ryanbr#513)

Swift twin of ryanbr#512. reconcileWorkoutHrWithTrace read every workout's HR window
under the Repository's single active deviceId, so a bout auto-detected on a
SECOND WHOOP (its hrSample rows banked under that strap's id) read the active
strap's empty window and its Avg HR / Effort went un-reconciled. Single-WHOOP
installs matched the active id, so only multi-WHOOP users were affected.

Resolve the read key per row via the new nonisolated static
Repository.workoutHrDeviceId(source:activeStrapId:): a DETECTED row's own
`source` IS its computed strap id ("<base>-noop"), so strip the suffix to read
HR under the raw "<base>". MANUAL and IMPORTED rows carry no strap id in
`source`, so they reconcile against the active strap as before (single-device:
"<base>-noop" strips to the active id, so the read is byte-identical).

Display-only projection (never persisted). New WorkoutHrDeviceKeyTests pins the
resolution. Parity note: the Kotlin twin also keys MANUAL rows on their stored
deviceId; the Swift WorkoutRow read-model carries no deviceId, so a manual
session created on a non-active strap reconciles here against the active strap
— a minor, display-only divergence; the detected-dominant multi-WHOOP bug is
fixed on both platforms.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ed my-whoop (ryanbr#767, ryanbr#717) (ryanbr#777)

Workout auto-detection silently stopped for live-BLE users while sleep,
charge and everything else kept syncing. Root cause: AutoWorkoutNudge read
HR via repo.hrSamples("my-whoop", ...) — a hardcoded id — but a live strap
banks its raw under its OWN fresh id ("whoop-<uuid>", ryanbr#908), so the read
returned nothing and detect() bailed at 'hr.size < 2'. The mid-July
active-strap-id migration (ryanbr#175/ryanbr#512/ryanbr#513) moved sleep/charge/health/iOS
reads to the active-strap union but missed this one.

Fix: read the hrSamplesUnion(activeDeviceId) — importedSourceIds(active) ∪
my-whoop, deduped — exactly what every other Today read and iOS
Repository.autoDetectCandidate() already use. Android-only; iOS was never
affected (it already reads the union). No behaviour change for single-WHOOP
installs, whose active id resolves to my-whoop (one id, identical read).

App-target Android; not compiled here (aapt2 x86-only on this host) - run
./gradlew compileFullDebugKotlin before merge.
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