Distinguish "connected, no data" from "not connected" (#612)#826
Distinguish "connected, no data" from "not connected" (#612)#826digitalerdude wants to merge 1 commit into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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.resolveonly checksconnectedin its first branch(
.recording). Fall through that, and a strap that's genuinely connected buthas 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
.historyExperimentaloverride — which still takes priority for a WHOOP5/MG past its 2-consecutive-empty-offload threshold, unchanged by this PR.
What changed
LiveState.sustainedEmptyOffload(new@Publishedbool, 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 everyHISTORY_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
thresholds are untouched.
testable
RecordingStateenum; a smaller follow-up.RecordingStatusChip/recordingStateForare currently notwired 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' buildand-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 thepre-existing copy tests are sensitive to regardless of this change).
./gradlew compileFullDebugKotlin— clean.the regression case (a healthy WHOOP 5.0 session still reading "Recording")
and the new
.connectedNoDatacase itself need a live check before thisleaves draft.
testFullDebugUnitTestcould not be run locally: an unrelated,pre-existing gap (three
FakeRegistryDaotest fakes are missing ~25methods
DeviceRegistryDaonow declares) fails test compilation for thewhole module. Not touched here; filed separately.
compile/unit-test verified only.