Skip to content

Workouts: backfill HR/calories on a manual entry from its detected bout (#510) - #708

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
digitalerdude:fix/510-workout-hr-calories-manual
Jul 22, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
digitalerdude:fix/510-workout-hr-calories-manual

Conversation

@digitalerdude

Copy link
Copy Markdown

Addresses the confirmed, narrowed part of #510: "the issue with the missing HR and calories in the workouts is still there. However, the values are only missing when I manually enter the workout afterward. When I start and end a workout through the app, the values are displayed correctly." (The Effort-score part of the original report turned out to be unrelated — wearing the strap on the bicep for a more accurate HR reading, per the reporter's own follow-up.)

Root cause

Confirmed directly against the reporter's own Test Centre → Workouts trace (attached to the issue):

[workouts] detectedBout verdict=droppedOverlap durMin=11 avgBpm=150 overlapSource=manual
[workouts] detectedBout verdict=droppedOverlap durMin=7 avgBpm=151 overlapSource=manual

IntelligenceEngine's auto-workout detector independently finds the same activity from raw HR + motion and computes a real avgHR/calories/maxHR/strain for it (avgBpm=150 right there in the trace) — then throws all of that away the instant it overlaps a real (manual/imported) row, purely to avoid persisting the same workout twice (#975).

Separately, WhoopRepository.fillWorkoutHrFromStrap (the display-time fill) reads HR strictly inside the row's own [startTs, endTs] window. A manual entry's user-typed boundaries rarely match the detector's own (tighter) boundaries for the same activity, so that raw-window read can come up under minSamples (60) even on a single-strap install with dense HR coverage — while the detector, using its own boundaries, found plenty. Net effect: real, already-computed HR/calories data existed for the exact activity and was silently discarded instead of being used.

Fix

When a detected bout collides with a real row, backfill only the fields that row doesn't already have — never touching anything typed by the user, imported, or previously filled — from the bout's own computed values, then upsert in place (same natural key (deviceId, startTs, sport), so this updates rather than duplicates).

Extracted the merge into a small, pure, tested helper on each platform so the logic is unit-testable without standing up the whole analyzeRecent pipeline:

  • Android: IntelligenceEngine.backfillWorkoutFromDetectedBout
  • Swift: WorkoutDetector.backfillWorkout (in Packages/StrandAnalytics, so it's covered by swift test rather than only the App-target test suite)

Added a new trace verdict droppedOverlapBackfilled (alongside the existing persisted / droppedOverlap) so the Workouts test-mode trace can distinguish "dropped and backfilled" from "dropped, nothing to add" — useful for the next report like this one.

Scope

Byte-parity: both platforms touched in this PR, same natural key / same field semantics, no analytics formula or stored-value shape changed — this only decides which of the fields a row already has get filled in from data the pipeline already computes.

Testing

  • New unit tests, both platforms, exercising fill / never-override / no-op-when-already-full:
    • Android: IntelligenceEngineWorkoutBackfillTest (3 tests)
    • Swift: WorkoutDetectorBackfillTests (3 tests)
  • ./gradlew compileFullDebugKotlin testFullDebugUnitTest --tests "com.noop.analytics.*" ✓ (JDK 17 via Homebrew)
  • swift test (Packages/StrandAnalytics): 1110 tests, 0 failures ✓
  • xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build — BUILD SUCCEEDED
  • xcodebuild -project Strand.xcodeproj -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build — BUILD SUCCEEDED
  • Not tested on-device — this only changes what gets written to already-computed rows during the periodic analytics pass, no BLE path touched

…ut (ryanbr#510)

A manually-entered (retroactive) workout can end up with no Avg HR /
calories even on a single-strap install with dense HR coverage: the
auto-workout detector finds the same activity from raw HR+motion,
computes a real avgHR/calories/maxHR/strain for it, then discards all
of that the moment it overlaps a real (manual/imported) row - purely
to avoid double-counting the same workout twice.

A manual entry's user-typed [startTs,endTs] rarely matches the
detector's own tighter boundaries, so the display-time strap-HR fill
(WhoopRepository.fillWorkoutHrFromStrap / raw window read) can come up
too thin (<60 samples) inside the row's own window even though the
detector, working from its own boundaries, found plenty.

Fix: when a detected bout collides with a real row, backfill only the
fields that row doesn't already have (never touching anything typed
by the user, imported, or already filled) from the bout's own computed
values, then upsert in place (same natural key). Extracted the merge
into a small pure, tested helper on each platform
(WorkoutDetector.backfillWorkout / IntelligenceEngine.backfillWorkoutFromDetectedBout).
New trace verdict "droppedOverlapBackfilled" distinguishes a backfilled
drop from a plain one in the Workouts test-mode trace.

Root cause confirmed against the reporter's own Test Centre workouts
trace: `detectedBout verdict=droppedOverlap durMin=11 avgBpm=150
overlapSource=manual` - a real avgBpm was computed and thrown away.
@digitalerdude
digitalerdude marked this pull request as ready for review July 21, 2026 15:45
@ryanbr
ryanbr merged commit 752f47c into ryanbr:main Jul 22, 2026
9 checks passed
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…alories-manual

Workouts: backfill HR/calories on a manual entry from its detected bout (ryanbr#510)
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…alories-manual

Workouts: backfill HR/calories on a manual entry from its detected bout (ryanbr#510)
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.

2 participants