Skip to content

Distinguish "connected, no data" from "not connected" (#612)#826

Draft
digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/612-connected-no-data
Draft

Distinguish "connected, no data" from "not connected" (#612)#826
digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/612-connected-no-data

Conversation

@digitalerdude

Copy link
Copy Markdown

Problem

#612 describes a strap that connects fine but never actually hands over data
(no live HR, offloads keep coming back empty) — and every surface still
honestly says "connected", while the Recording chip specifically does not:
RecordingState.resolve only checks connected in its first branch
(.recording). Fall through that, and a strap that's genuinely connected but
has never once synced — a fresh pairing, or an established WHOOP-4/generic
strap whose last several offloads are a sustained empty streak — reads
"Not recording. Strap not connected. Tap to connect.", which is simply
false.

Part 1 of #612 (surfacing the age of the last banked night on the baseline
card) already shipped. This is part 2, the "connected, but no data" signal,
scoped specifically to the gap that isn't already covered by #580's
.historyExperimental override — which still takes priority for a WHOOP
5/MG past its 2-consecutive-empty-offload threshold, unchanged by this PR.

What changed

  • LiveState.sustainedEmptyOffload (new @Published bool, both platforms):
    exposes the WHOOP-4/generic empty-offload streak that EmptySyncTracker
    (and its Kotlin twin) already tracked, but which previously only reached
    the UI as free text inside lastSyncError. Set on every HISTORY_COMPLETE
    (so a productive sync clears it right away), reset on disconnect (the
    display flag only — the underlying streak counter deliberately survives a
    reconnect, unchanged existing behavior).
  • RecordingState.connectedNoData (Swift + Kotlin, identical case/priority):
    connected && heartRate == nil && (lastSyncedAt == nil || sustainedEmptyOffload).
    Label stays "Connected" (still true), detail: "No live heart rate or synced
    history yet this session."

Explicitly out of scope here

  • No new/more sensitive trigger — the existing 3×/2× consecutive-empty
    thresholds are untouched.
  • No clickable single-bond README hint. That's a layer above the pure,
    testable RecordingState enum; a smaller follow-up.
  • Android's RecordingStatusChip/recordingStateFor are currently not
    wired into any screen
    — pre-existing, unrelated to this PR. The Kotlin
    mirror here keeps parity for whenever that does get wired, per
    RecordingState's own doc-comment promise of a 1:1 Kotlin mirror.

Verification

  • xcodebuild … -scheme Strand -destination 'platform=macOS' build and
    -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' build — both clean.
  • StrandTests/TodayExplainabilityTests, 46/46 (needed -testLanguage en -testRegion US — this machine's system locale is German, which the
    pre-existing copy tests are sensitive to regardless of this change).
  • ./gradlew compileFullDebugKotlin — clean.
  • Not verified on real hardware this session (no strap connected). Both
    the regression case (a healthy WHOOP 5.0 session still reading "Recording")
    and the new .connectedNoData case itself need a live check before this
    leaves draft.
  • Android's testFullDebugUnitTest could not be run locally: an unrelated,
    pre-existing gap (three FakeRegistryDao test fakes are missing ~25
    methods DeviceRegistryDao now declares) fails test compilation for the
    whole module. Not touched here; filed separately.
  • No Android device+strap test setup — the Android side of this PR is
    compile/unit-test verified only.

The strap-recording chip only checked `connected` in its first branch
(`.recording`). A strap that is genuinely connected but has never once
synced — a fresh pairing, or a WHOOP-4/generic strap whose recent offloads
are a sustained streak of empty — fell through to `.notRecording` and showed
"Strap not connected. Tap to connect.", which is simply false: the link is up.
That's the exact symptom ryanbr#612 describes ("the app doesn't say what's really
happening") for the case that isn't already covered by ryanbr#580's
`.historyExperimental` override (which still takes priority for a WHOOP 5/MG
past its 2-empty-offload threshold — unchanged here).

Adds `LiveState.sustainedEmptyOffload`, exposing the WHOOP-4/generic
empty-offload streak (`EmptySyncTracker` / Android's twin) that was
previously only reachable as free text in `lastSyncError`. Set on every
HISTORY_COMPLETE (so a productive sync clears it immediately), reset on
disconnect (display flag only — the underlying streak counter deliberately
survives a reconnect, unchanged). `RecordingState` gains `.connectedNoData`:
connected, no live HR, and either never-synced or sustained-empty. Kotlin
gets the identical case, param and priority order in `TodayScoring.kt`,
per `RecordingState`'s own doc-comment promise of a 1:1 Kotlin mirror —
though note Android's `RecordingStatusChip`/`recordingStateFor` are
currently unwired into any screen (pre-existing, unrelated gap; will flag
separately).

Deliberately not done: no new, more sensitive trigger (the existing 3×/2×
consecutive-empty thresholds are unchanged), and no clickable single-bond
README hint (belongs a layer above the pure `RecordingState` enum — a
smaller follow-up).

Verified: xcodebuild build for Strand (macOS) and NOOPiOS.
StrandTests/TodayExplainabilityTests (46/46, forcing -testLanguage en
-testRegion US — this Mac's system locale is German, which the existing
copy tests are sensitive to regardless of this change).
`./gradlew compileFullDebugKotlin` clean. Android's `testFullDebugUnitTest`
could not be run locally — an unrelated, pre-existing gap (three
FakeRegistryDao test fakes are missing ~25 methods DeviceRegistryDao now
declares) fails test compilation for the whole module; filed separately,
not touched here.

Not verified on real hardware this session (no strap connected). Both the
regression case (a healthy WHOOP 5.0 session still reading "Recording") and
the new `.connectedNoData` case (a fresh pairing, or a WHOOP-4/generic
strap's 3rd consecutive empty offload) need a live check before this leaves
draft. No Android device+strap test setup either way — that side is
compile/unit-test verified only.

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid — the state genuinely was lying ("Strap not connected" on a live link), both platforms move together, and scoping around #580's .historyExperimental override rather than competing with it is the right call. I checked the priority: historySyncExperimental is still tested before resolve, so that path is untouched as you say. Conditions are identical on both sides.

One thing to fix: the new copy is missing from the String Catalog.

"Strap not connected. Tap to connect."                    -> in catalog
"History sync is experimental on 5.0."                    -> in catalog
"No live heart rate or synced history yet this session."  -> ABSENT
"Connected. No live heart rate or synced history…"        -> ABSENT

LocalizedStringKey / String(localized:) is the right mechanism, so English is fine — but de/es/fr/pt-PT will show these two in English while every neighbouring chip string is translated. Both siblings are already in Localizable.xcstrings, so the convention is clear.

Worth knowing why CI stayed green: i18n_audit checks that each entry already in the catalog has every language. It cannot see a string that never made it into the catalog at all. Same blind-spot class as the Text(variable) gap.

The Kotlin literals are fine as-is — the whole enum is unlocalized there already and, as you note, the chip is not wired into any screen yet.

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