Skip to content

backfill: distinguish no-flash-cursor from writing-but-recording-nothing in the empty-offload banner (#1754) - #1965

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
evoveotech:fix/1754-empty-offload-guidance
Sep 7, 2026
Merged

ryanbr merged 2 commits into
ryanbr:mainfrom
evoveotech:fix/1754-empty-offload-guidance

Conversation

@evoveotech

Copy link
Copy Markdown

Summary

  • An offload that persists zero sensor records always said the same thing: "your strap had no stored history to hand over... This usually means its clock has lost sync... Fully charge it to 100%..." That copy was written for the no-flash-cursor state (trim=0xFFFFFFFF), but the banner is gated only on sessionRowsPersisted == 0, so it also fires for a strap whose flash cursor is perfectly healthy — and then names a cause that demonstrably does not apply.
  • A WHOOP 4.0 capture showed exactly this: a valid, advancing write pointer (106970 → 106972), a healthy clock (drift -1s), and every page carrying console output only. The user is told to charge it because its clock lost sync. Charging will not change any of the above, and the message sends them away from the real cause.
  • The fix exposes Backfiller.sawNoFlashCursor (was private loggedNoCursor) on both platforms and uses it at the banner decision point to choose between two named states:
    • No flash cursor (trim=0xFFFFFFFF): the existing clock/charge copy, now Backfiller.noFlashCursorBanner.
    • Valid, advancing cursor, zero sensor rows: a new Backfiller.noSensorRecordsBanner that says this is NOT a clock problem, points at the sensor front-end or power state, and asks for a strap log rather than promising that charging will fix it.
  • Both banners are byte-identical across Swift and Kotlin, kept as named constants so the caller's branch reads as a choice between two states rather than two inline string literals. The staleNewest dated banner still takes precedence over both (it is the most actionable when it applies).

Cross-platform parity

  • Swift Backfiller.sawNoFlashCursor + noFlashCursorBanner + noSensorRecordsBanner mirror the Kotlin twins exactly.
  • Tests on both platforms pin the wording and the distinction.

Files

  • Strand/Collect/Backfiller.swift — exposed sawNoFlashCursor; added noFlashCursorBanner + noSensorRecordsBanner constants.
  • Strand/BLE/BLEManager.swift — banner decision now branches on sawNoFlashCursor.
  • StrandTests/BackfillerSessionTallyTests.swift — 4 new tests.
  • android/.../ble/Backfiller.kt — exposed sawNoFlashCursor; added noFlashCursorBanner + noSensorRecordsBanner constants.
  • android/.../ble/WhoopBleClient.kt — banner decision now branches on sawNoFlashCursor.
  • android/.../ble/BackfillerSessionTallyTest.kt — 4 new tests.

Test plan

  • cd android && ./gradlew testFullDebugUnitTest --tests "com.noop.ble.BackfillerSessionTallyTest" — all existing + 4 new tests
  • cd android && ./gradlew compileFullDebugKotlin — no compile errors
  • xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build — app-target Swift compiles
  • Hardware: a WHOOP 4.0 with a valid, advancing flash cursor but zero sensor rows should now see the "not a clock problem" banner instead of the "charge to 100%" banner

Generated with Devin

github-actions Bot and others added 2 commits September 7, 2026 17:45
…ing in the empty-offload banner (ryanbr#1754)

An offload that persists zero sensor records always said the same thing:
"your strap had no stored history to hand over... This usually means its
clock has lost sync... Fully charge it to 100%..."

That copy was written for the no-flash-cursor state (trim=0xFFFFFFFF),
which Backfiller detects and reports separately with the same cause. But
the banner is gated only on `sessionRowsPersisted == 0` at
HISTORY_COMPLETE, so it also fires for a strap whose flash cursor is
perfectly healthy — and then names a cause that demonstrably does not
apply.

A WHOOP 4.0 capture showed exactly this: a valid, advancing write pointer
(106970 → 106972), a healthy clock (drift -1s), and every page carrying
console output only. The user is told to charge it because its clock lost
sync. Charging will not change any of the above, and the message sends
them away from the real cause.

The fix exposes `Backfiller.sawNoFlashCursor` (was private
`loggedNoCursor`) on both platforms and uses it at the banner decision
point to choose between two named states:

- No flash cursor (trim=0xFFFFFFFF): the existing clock/charge copy,
  now `Backfiller.noFlashCursorBanner`.
- Valid, advancing cursor, zero sensor rows: a new
  `Backfiller.noSensorRecordsBanner` that says this is NOT a clock
  problem, points at the sensor front-end or power state, and asks for
  a strap log rather than promising that charging will fix it.

Both banners are byte-identical across Swift and Kotlin, kept as named
constants so the caller's branch reads as a choice between two states
rather than two inline string literals. The `staleNewest` dated banner
still takes precedence over both (it is the most actionable when it
applies).

Cross-platform parity: Swift `Backfiller.sawNoFlashCursor` +
`noFlashCursorBanner` + `noSensorRecordsBanner` mirror the Kotlin twins
exactly. Tests on both platforms pin the wording and the distinction.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@ryanbr
ryanbr merged commit 6fd49b0 into ryanbr:main Sep 7, 2026
5 checks passed
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