Skip to content

Future-dated strap (#324): surface a banner + bad-clock diagnostics - #332

Merged
ryanbr merged 4 commits into
mainfrom
future-dated-strap-banner-324
Jul 12, 2026
Merged

ryanbr merged 4 commits into
mainfrom
future-dated-strap-banner-324

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Addresses #324 (WHOOP 4.0 whose RTC relatched to a future base: banked history dated years ahead, offload silently importing nothing). Two clearly-separated concerns, both on the shared cross-platform seam.

Root cause

The reporter's strap live-clock correlates fine (~2026) but its flash-banked history spans 2028-2029 and its alarm reads back epoch 2052 — the RTC relatched to a future base. The existing #928/#1012/#773 guards correctly REFUSE to misfile that data 3 years ahead. But two gaps made the failure illegible:

1. User-facing banner (the fix)

Because a future-dated strap DOES bank records, the existing "clock lost / not banking" banner (gated on empty syncs) never fired — so the sync reported clean (or the misleading "strap went quiet" on timeout) while importing nothing, with no reason shown.

  • New pure futureDatedStrapBanner (Swift BLEManager + Kotlin WhoopBleClient, byte-identical string), driven by the existing isFutureDatedNewest predicate.
  • Wired into BOTH the HISTORY_COMPLETE and the WHOOP-4 timeout outcomes (a deep future-dated backlog TIMES OUT as readily as it completes — the reporter's session ended on timeout).
  • Names the strap-side remedy: full charge + power-cycle to relatch the clock.

2. Bad-clock diagnostics (dev logs, log-only)

No behavior change — the #547 gate still drops exactly what it dropped before; these just make the cause legible in a strap-log export.

  • RTC-state events (RTC_LOST / BOOT / SET_RTC) that the gate drops for their OWN implausible timestamp are now captured (Streams.droppedRtcEvents) before being discarded — the catch-22 fix, since the one event that explains the reset was silently dropped because ITS ts is bad. Logged with the bad epoch the RTC jumped to.
  • Dropped-record epoch span (oldest -> newest own-timestamp + hours-ahead skew), so a future strap-log can tell a whole-range-future strap from one mixed with real data.
  • Capture lives in pure WhoopProtocol.extractHistoricalStreams; formatting in a pure, byte-identical BadClockDiagnostics (Swift + Kotlin twins).

Not in scope (deliberate)

  • Does NOT recover the already-banked future-dated data (still correctly refused).
  • Does NOT add a strap-side reset/history-clear (the BLE safety contract forbids destructive TRIM_ALL_DATA).
  • A follow-up fast-forward-discard (drain the poisoned backlog quickly, informed by the new span log) is the next step, and is hardware-gated.

Validation

  • WhoopProtocol 271 swift test (+12 new: capture + formatter twins)
  • Android compileFullDebugKotlin + testFullDebugUnitTest green
  • app-build macOS Strand + iOS NOOPiOS green (the app-target BLEManager/Backfiller changes have no default CI)
  • Banner + RTC-log strings verified byte-identical Swift<->Kotlin

BLE-path behavior (the banner firing on a real future-dated strap) is validated by the reporter on-strap; compile + the pure-predicate tests prove the wiring.

ryanbr added 4 commits July 12, 2026 21:54
A WHOOP 4.0 whose RTC relatched to a future base banks history dated
years ahead. The #928/#1012/#773 guards correctly refuse to misfile it,
but because the strap DOES bank records the existing 'clock lost / not
banking' banner (gated on empty syncs) never fires - so the sync reported
clean while importing nothing, with no reason shown (#324).

Add a pure futureDatedStrapBanner (Swift BLEManager + Kotlin
WhoopBleClient, byte-identical string) driven by the existing
isFutureDatedNewest predicate, wired into the HISTORY_COMPLETE handler
after the banked-nothing branch. One detection is decisive (nothing
legitimate banks 48h ahead), so no streak gate. Names the strap-side
remedy (full charge + power-cycle to relatch the clock).

Twin tests pin the string + the 48h threshold on both platforms.
Re-review caught the real gap: a future-dated strap with a deep backlog
TIMES OUT before HISTORY_COMPLETE (the reporter's own session ended on
timeout). The first cut gated the banner on HISTORY_COMPLETE only, so the
reported case would still show the misleading 'strap went quiet' message
and the new banner would never fire.

Hoist the future-clock banner (computed once from strapNewestTs, which is
already populated by the connect-time GET_DATA_RANGE) and apply it to BOTH
the HISTORY_COMPLETE branch and the WHOOP-4 timeout branch, on both
platforms. The log is now outcome-independent too.
…read

Re-review found a Swift/Kotlin divergence I introduced: replacing Kotlin's
'else null' with 'else futureClockBanner' meant a strap that banked nothing
this pass but isn't yet sustained-empty AND is future-dated showed the
future banner on Android while Swift (bankedNothing branch wins) showed nil.
Restructure the Kotlin lastSyncError to a when{} that matches Swift's
else-if precedence exactly: bankedNothing keeps its sustained-empty logic
(#126/#214), future-dated is checked only on the banked-something path.

Also hoist the Swift wall-clock read to one value (parity with Kotlin's
single nowSec; the banner and its log can't drift across a second boundary).
Two log-only additions to make a future-dated strap's cause legible in the
strap-log export, extending the existing #547 dropped-record seam:

1. RTC-state events (RTC_LOST / BOOT / SET_RTC) that the #547 plausibility
   gate drops for their own implausible timestamp are now CAPTURED before
   being discarded (Streams.droppedRtcEvents). This is the catch-22 fix: the
   one event that explains the reset was silently dropped because ITS ts is
   bad. The Backfiller logs each with the bad epoch the RTC jumped to.

2. The epoch SPAN of the dropped records (oldest -> newest own-timestamp +
   hours-ahead skew) is captured and logged, so we can tell a whole-range-
   future strap (safe to fast-forward-discard, the #324 follow-up B) from one
   mixed with real data.

Pure capture in WhoopProtocol.extractHistoricalStreams + a pure, testable
BadClockDiagnostics formatter (byte-identical Swift/Kotlin), both twinned.
No behavior change: the gate still drops exactly what it dropped before.
WhoopProtocol 271 swift test + Android compile/tests green.
@ryanbr
ryanbr merged commit 694ae22 into main Jul 12, 2026
10 checks passed
@ryanbr
ryanbr deleted the future-dated-strap-banner-324 branch July 12, 2026 10:32
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…tics (ryanbr#332)

* Surface a banner when the strap clock is set in the future (ryanbr#324)

A WHOOP 4.0 whose RTC relatched to a future base banks history dated
years ahead. The ryanbr#928/ryanbr#1012/ryanbr#773 guards correctly refuse to misfile it,
but because the strap DOES bank records the existing 'clock lost / not
banking' banner (gated on empty syncs) never fires - so the sync reported
clean while importing nothing, with no reason shown (ryanbr#324).

Add a pure futureDatedStrapBanner (Swift BLEManager + Kotlin
WhoopBleClient, byte-identical string) driven by the existing
isFutureDatedNewest predicate, wired into the HISTORY_COMPLETE handler
after the banked-nothing branch. One detection is decisive (nothing
legitimate banks 48h ahead), so no streak gate. Names the strap-side
remedy (full charge + power-cycle to relatch the clock).

Twin tests pin the string + the 48h threshold on both platforms.

* Fix ryanbr#324 banner: cover the timeout outcome, not just HISTORY_COMPLETE

Re-review caught the real gap: a future-dated strap with a deep backlog
TIMES OUT before HISTORY_COMPLETE (the reporter's own session ended on
timeout). The first cut gated the banner on HISTORY_COMPLETE only, so the
reported case would still show the misleading 'strap went quiet' message
and the new banner would never fire.

Hoist the future-clock banner (computed once from strapNewestTs, which is
already populated by the connect-time GET_DATA_RANGE) and apply it to BOTH
the HISTORY_COMPLETE branch and the WHOOP-4 timeout branch, on both
platforms. The log is now outcome-independent too.

* Fix ryanbr#324 banner parity: bankedNothing precedence + single wall-clock read

Re-review found a Swift/Kotlin divergence I introduced: replacing Kotlin's
'else null' with 'else futureClockBanner' meant a strap that banked nothing
this pass but isn't yet sustained-empty AND is future-dated showed the
future banner on Android while Swift (bankedNothing branch wins) showed nil.
Restructure the Kotlin lastSyncError to a when{} that matches Swift's
else-if precedence exactly: bankedNothing keeps its sustained-empty logic
(ryanbr#126/ryanbr#214), future-dated is checked only on the banked-something path.

Also hoist the Swift wall-clock read to one value (parity with Kotlin's
single nowSec; the banner and its log can't drift across a second boundary).

* Add bad-clock strap diagnostics for ryanbr#324 (dev logs 1 + 2)

Two log-only additions to make a future-dated strap's cause legible in the
strap-log export, extending the existing ryanbr#547 dropped-record seam:

1. RTC-state events (RTC_LOST / BOOT / SET_RTC) that the ryanbr#547 plausibility
   gate drops for their own implausible timestamp are now CAPTURED before
   being discarded (Streams.droppedRtcEvents). This is the catch-22 fix: the
   one event that explains the reset was silently dropped because ITS ts is
   bad. The Backfiller logs each with the bad epoch the RTC jumped to.

2. The epoch SPAN of the dropped records (oldest -> newest own-timestamp +
   hours-ahead skew) is captured and logged, so we can tell a whole-range-
   future strap (safe to fast-forward-discard, the ryanbr#324 follow-up B) from one
   mixed with real data.

Pure capture in WhoopProtocol.extractHistoricalStreams + a pure, testable
BadClockDiagnostics formatter (byte-identical Swift/Kotlin), both twinned.
No behavior change: the gate still drops exactly what it dropped before.
WhoopProtocol 271 swift test + Android compile/tests green.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…tics (ryanbr#332)

* Surface a banner when the strap clock is set in the future (ryanbr#324)

A WHOOP 4.0 whose RTC relatched to a future base banks history dated
years ahead. The ryanbr#928/ryanbr#1012/ryanbr#773 guards correctly refuse to misfile it,
but because the strap DOES bank records the existing 'clock lost / not
banking' banner (gated on empty syncs) never fires - so the sync reported
clean while importing nothing, with no reason shown (ryanbr#324).

Add a pure futureDatedStrapBanner (Swift BLEManager + Kotlin
WhoopBleClient, byte-identical string) driven by the existing
isFutureDatedNewest predicate, wired into the HISTORY_COMPLETE handler
after the banked-nothing branch. One detection is decisive (nothing
legitimate banks 48h ahead), so no streak gate. Names the strap-side
remedy (full charge + power-cycle to relatch the clock).

Twin tests pin the string + the 48h threshold on both platforms.

* Fix ryanbr#324 banner: cover the timeout outcome, not just HISTORY_COMPLETE

Re-review caught the real gap: a future-dated strap with a deep backlog
TIMES OUT before HISTORY_COMPLETE (the reporter's own session ended on
timeout). The first cut gated the banner on HISTORY_COMPLETE only, so the
reported case would still show the misleading 'strap went quiet' message
and the new banner would never fire.

Hoist the future-clock banner (computed once from strapNewestTs, which is
already populated by the connect-time GET_DATA_RANGE) and apply it to BOTH
the HISTORY_COMPLETE branch and the WHOOP-4 timeout branch, on both
platforms. The log is now outcome-independent too.

* Fix ryanbr#324 banner parity: bankedNothing precedence + single wall-clock read

Re-review found a Swift/Kotlin divergence I introduced: replacing Kotlin's
'else null' with 'else futureClockBanner' meant a strap that banked nothing
this pass but isn't yet sustained-empty AND is future-dated showed the
future banner on Android while Swift (bankedNothing branch wins) showed nil.
Restructure the Kotlin lastSyncError to a when{} that matches Swift's
else-if precedence exactly: bankedNothing keeps its sustained-empty logic
(ryanbr#126/ryanbr#214), future-dated is checked only on the banked-something path.

Also hoist the Swift wall-clock read to one value (parity with Kotlin's
single nowSec; the banner and its log can't drift across a second boundary).

* Add bad-clock strap diagnostics for ryanbr#324 (dev logs 1 + 2)

Two log-only additions to make a future-dated strap's cause legible in the
strap-log export, extending the existing ryanbr#547 dropped-record seam:

1. RTC-state events (RTC_LOST / BOOT / SET_RTC) that the ryanbr#547 plausibility
   gate drops for their own implausible timestamp are now CAPTURED before
   being discarded (Streams.droppedRtcEvents). This is the catch-22 fix: the
   one event that explains the reset was silently dropped because ITS ts is
   bad. The Backfiller logs each with the bad epoch the RTC jumped to.

2. The epoch SPAN of the dropped records (oldest -> newest own-timestamp +
   hours-ahead skew) is captured and logged, so we can tell a whole-range-
   future strap (safe to fast-forward-discard, the ryanbr#324 follow-up B) from one
   mixed with real data.

Pure capture in WhoopProtocol.extractHistoricalStreams + a pure, testable
BadClockDiagnostics formatter (byte-identical Swift/Kotlin), both twinned.
No behavior change: the gate still drops exactly what it dropped before.
WhoopProtocol 271 swift test + Android compile/tests green.
ryanbr added a commit that referenced this pull request Aug 28, 2026
…roid (#1703)

Reported as #1702. The two platforms rendered the same section from different data: Android
windows to today-13 at start of day and says "14 days" in the header, while iOS drew from
repo.workoutRows(), whose default is days: 4000 — effectively all-time — under a heading that
says "Latest", trailed by a count of every workout ever recorded. One account read two ways:
someone who last trained three months ago saw no section on Android and six stale sessions on
iOS.

The call sites suggest iOS's behaviour was never a product decision. Android keeps two sets,
recentUnion for the feed and separate all-time queries for the Data Sources counts, while iOS
has a single `workouts` array serving both and the section used what was already there.

That is why the window is applied at the SECTION, not to the array. `workouts` also feeds the
Data Sources Apple-workout count and the HR chart's sport glyphs, both all-time by design, so
windowing it at the source compiles, looks correct, and quietly shrinks two unrelated numbers
on the same screen. A parity assertion in ordinary CI now checks that count still reads the
unwindowed array, so the trap is guarded rather than sidestepped once.

The trailing header describes the window on both platforms; "14 days" was already in the
catalogue in all ten locales, so no new copy. Tile caps stay 4 and 6 deliberately — that is
layout, not content: Android is single-column full-width on purpose (#332), iOS uses an
adaptive grid.

This changes iOS behaviour: the section now disappears when there has been no training for two
weeks, matching Android. That is the intent, not a side effect.

Verified: 4,713 Android tests, 0 failures with --rerun-tasks --no-build-cache; i18n audit
clean; app-build green on both legs, with all five TodayWorkoutsWindowTests confirmed by name
in the log rather than inferred from an aggregate pass. Those pin the cutoff boundary — kept
exactly on it, dropped one second earlier — because an off-by-one there silently drops a
session the other platform still shows.
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