diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index aaad54f415..b212dfe80c 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1934,6 +1934,10 @@ public final class BLEManager: NSObject, ObservableObject { let bankedNothing = banking.bankedNothing && !productiveBurstTail let sustainedEmpty = productiveBurstTail ? false : emptySyncTracker.recordCompletedSync( bankedSensorRecords: bankedSensorRecords, consoleOnly: banking.bankedNothing) + // #612: expose the streak as a queryable flag (previously only reached UI as `lastSyncError` + // free text below). Set on EVERY HISTORY_COMPLETE, not just the empty branch, so a productive + // sync clears it immediately. + state.sustainedEmptyOffload = sustainedEmpty // #57 debug: write-health for the export. Distinguish "rows actually landed" from "an offload // STALLED on a persist failure" — the latter (usually a restore without a restart) is otherwise // invisible in a report that just shows "0 synced". @@ -3885,6 +3889,10 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // re-derives it. (The honest flag is per-link, like the syncing pill / reject counters above.) whoop5EmptyOffload.reset() state.historySyncExperimental = false + // #612: the display flag only, not the underlying emptySyncTracker streak (that counter + // deliberately survives a reconnect — unchanged, existing behaviour). A fresh link re-derives + // this from its own next HISTORY_COMPLETE. + state.sustainedEmptyOffload = false backfillTimeout?.cancel() backfillTimeout = nil backfillFrameQueue.removeAll() diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index 46b3fb2122..d9d181668f 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -265,6 +265,12 @@ public final class LiveState: ObservableObject { /// experimental on 5.0" instead of a WHOOP-4-style "not recording"/sync-error. Reset on connect/disconnect. @Published public var historySyncExperimental: Bool = false + /// #612 — true when the WHOOP-4/generic empty-offload streak (`EmptySyncTracker`, `BLEManager`) is + /// currently SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not + /// coupled to HR: a connected strap that keeps handing over nothing has this true regardless of + /// whether live HR is streaming. Reset on disconnect; re-derived from the next offload. + @Published public var sustainedEmptyOffload: Bool = false + // MARK: - Standard fitness-sensor live metrics (RSC / CSC / CPS — additive, never HR) // // Live instantaneous speed / cadence / power from a connected standard fitness sensor (a footpod, a diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index d7826b593c..16a97aca9a 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -170616,6 +170616,110 @@ } } } + }, + "No live heart rate or synced history yet this session.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Live-Herzfrequenz und kein synchronisierter Verlauf in dieser Sitzung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay frecuencia cardíaca en vivo ni historial sincronizado en esta sesión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune fréquence cardiaque en direct ni historique synchronisé durant cette session." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ancora nessuna frequenza cardiaca in diretta né cronologia sincronizzata in questa sessione." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ainda não há frequência cardíaca em direto nem histórico sincronizado nesta sessão." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пока нет ни пульса в реальном времени, ни синхронизированной истории в этой сессии." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本次连接尚无实时心率或已同步的历史记录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本次連接尚無即時心率或已同步的歷史記錄。" + } + } + } + }, + "Connected. No live heart rate or synced history yet this session.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbunden. Noch keine Live-Herzfrequenz und kein synchronisierter Verlauf in dieser Sitzung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectado. Aún no hay frecuencia cardíaca en vivo ni historial sincronizado en esta sesión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecté. Aucune fréquence cardiaque en direct ni historique synchronisé durant cette session." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Collegata. Ancora nessuna frequenza cardiaca in diretta né cronologia sincronizzata in questa sessione." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ligado. Ainda não há frequência cardíaca em direto nem histórico sincronizado nesta sessão." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено. Пока нет ни пульса в реальном времени, ни синхронизированной истории в этой сессии." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已连接。本次连接尚无实时心率或已同步的历史记录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已連接。本次連接尚無即時心率或已同步的歷史記錄。" + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index a5443e8446..9df9a48ac0 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -741,7 +741,8 @@ struct TodayView: View { if live.connected && live.historySyncExperimental { return .historyExperimental } return RecordingState.resolve(connected: live.connected, heartRate: live.heartRate, - lastSyncedAt: live.lastSyncedAt) + lastSyncedAt: live.lastSyncedAt, + sustainedEmptyOffload: live.sustainedEmptyOffload) } // MARK: Component 4, provenance badge (the real per-day merge winner) @@ -4552,6 +4553,7 @@ private struct RecordingStatusLight: View { case .lastSynced: return StrandPalette.statusWarning case .notRecording: return Color(red: 0.98, green: 0.27, blue: 0.23) case .historyExperimental: return StrandPalette.accent + case .connectedNoData: return StrandPalette.accent } } @@ -4864,6 +4866,11 @@ enum RecordingState: Equatable { /// offload yet. NOT the WHOOP-4 "not recording" failure: the link is live, history sync is just /// experimental on 5.0. Surfaced from `LiveState.historySyncExperimental`, overriding the mapper. case historyExperimental + /// #612, connected with no live HR AND no evidence data is actually flowing — either this is the + /// strap's first-ever pairing (never once synced) or a WHOOP-4/generic strap whose last several + /// offloads all came back empty (`LiveState.sustainedEmptyOffload`). Distinct from `.notRecording`: + /// the link genuinely IS up, so claiming "Strap not connected" would be false. + case connectedNoData /// The chip's short label. Verbatim spec copy; the dynamic "Xm" goes into the LocalizedStringKey slot. var label: LocalizedStringKey { @@ -4872,6 +4879,7 @@ enum RecordingState: Equatable { case .lastSynced(let mins): return "Last synced \(mins)m ago" case .notRecording: return "Not recording" case .historyExperimental: return "Connected" + case .connectedNoData: return "Connected" } } @@ -4882,6 +4890,7 @@ enum RecordingState: Equatable { case .lastSynced: return "Reconnect to pull the latest." case .notRecording: return "Strap not connected. Tap to connect." case .historyExperimental: return "History sync is experimental on 5.0." + case .connectedNoData: return "No live heart rate or synced history yet this session." } } @@ -4896,20 +4905,28 @@ enum RecordingState: Equatable { return String(localized: "Not recording. Strap not connected. Tap to connect.") case .historyExperimental: return String(localized: "Connected. History sync is experimental on 5.0.") + case .connectedNoData: + return String(localized: "Connected. No live heart rate or synced history yet this session.") } } /// PURE mapper (unit-testable), `recording` IFF (connected AND a live heart-rate sample is currently /// present). A connection with no live HR yet (handshaking, no PPG, strap off the wrist) is honestly - /// NOT recording. Otherwise, if a last-sync time is known, reads "Last synced Xm ago"; else "Not - /// recording". `lastSyncedAt` / `now` are unix seconds; the minute count clamps at >= 0 (strap-clock - /// skew can't read negative) and uses ceil so a 30-second-old sync reads "1m ago" rather than "0m ago". - /// Mirror EXACTLY in Kotlin. + /// NOT recording — but a genuinely connected strap that has never once synced, or one whose recent + /// offloads are a SUSTAINED streak of empty (`sustainedEmptyOffload`, #612), still IS connected, so + /// it reads `.connectedNoData` rather than the false "Strap not connected". Otherwise, if a last-sync + /// time is known, reads "Last synced Xm ago"; else "Not recording". `lastSyncedAt` / `now` are unix + /// seconds; the minute count clamps at >= 0 (strap-clock skew can't read negative) and uses ceil so a + /// 30-second-old sync reads "1m ago" rather than "0m ago". Mirror EXACTLY in Kotlin. static func resolve(connected: Bool, heartRate: Int?, lastSyncedAt: TimeInterval?, + sustainedEmptyOffload: Bool = false, now: TimeInterval = Date().timeIntervalSince1970) -> RecordingState { if connected && heartRate != nil { return .recording } + if connected && heartRate == nil && (lastSyncedAt == nil || sustainedEmptyOffload) { + return .connectedNoData + } if let at = lastSyncedAt { let secs = max(0, now - at) let mins = Int((secs / 60).rounded(.up)) diff --git a/StrandTests/TodayExplainabilityTests.swift b/StrandTests/TodayExplainabilityTests.swift index 66d7a3732f..44f131b1f0 100644 --- a/StrandTests/TodayExplainabilityTests.swift +++ b/StrandTests/TodayExplainabilityTests.swift @@ -176,9 +176,29 @@ final class TodayExplainabilityTests: XCTestCase { XCTAssertEqual(s, .lastSynced(minutesAgo: 2)) } - func testRecordingState_connectedNoLiveHR_noSync_isNotRecording() { - // Connected, no live HR, and nothing ever synced → "Not recording" (never a false "Recording"). + func testRecordingState_connectedNoLiveHR_noSync_isConnectedNoData() { + // #612: connected, no live HR, and nothing EVER synced (a strap's first-ever pairing) must not + // claim "Not recording. Strap not connected." — the link genuinely IS up. Fixed from the prior + // `.notRecording` result, which was the exact bug #612 reported. let s = RecordingState.resolve(connected: true, heartRate: nil, lastSyncedAt: nil, now: 10_000) + XCTAssertEqual(s, .connectedNoData) + } + + func testRecordingState_sustainedEmptyOffload_overridesAnOldLastSync() { + // #612: an established strap (old but known last-sync) whose recent offloads are a SUSTAINED + // empty streak reads "connected, no data" rather than a stale "Last synced 14d ago" that implies + // the last sync was just unusually long ago rather than actively failing right now. + let now: TimeInterval = 10_000 + let s = RecordingState.resolve(connected: true, heartRate: nil, lastSyncedAt: now - 1_209_600, + sustainedEmptyOffload: true, now: now) + XCTAssertEqual(s, .connectedNoData) + } + + func testRecordingState_sustainedEmptyOffload_withoutConnection_isNotConnectedNoData() { + // The `connected` gate still applies: a disconnected strap with a stale sustained-empty flag + // (carried over from before the drop) must not read "Connected". + let s = RecordingState.resolve(connected: false, heartRate: nil, lastSyncedAt: nil, + sustainedEmptyOffload: true, now: 10_000) XCTAssertEqual(s, .notRecording) } @@ -238,8 +258,14 @@ final class TodayExplainabilityTests: XCTestCase { "Not recording. Strap not connected. Tap to connect.") } + func testRecordingState_connectedNoData_copy() { + XCTAssertEqual(RecordingState.connectedNoData.accessibilityText, + "Connected. No live heart rate or synced history yet this session.") + } + func testRecordingState_copy_hasNoEmDash() { - let states: [RecordingState] = [.recording, .lastSynced(minutesAgo: 5), .notRecording] + let states: [RecordingState] = [.recording, .lastSynced(minutesAgo: 5), .notRecording, + .historyExperimental, .connectedNoData] for s in states { XCTAssertFalse(s.accessibilityText.contains("\u{2014}"), "RecordingState \(s) must not contain an em-dash") diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index cca5742ce4..eb992ec22b 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -212,6 +212,11 @@ data class LiveState( * once empty offloads are SUSTAINED; cleared on connect or once the strap banks real records. Twin of * macOS LiveState.historySyncExperimental. */ val historySyncExperimental: Boolean = false, + /** #612: TRUE when the WHOOP-4/generic empty-offload streak ([emptySyncTracker]) is currently + * SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not coupled to HR: + * a connected strap that keeps handing over nothing has this true regardless of live-HR status. + * Cleared on disconnect; re-derived from the next offload. Twin of macOS LiveState.sustainedEmptyOffload. */ + val sustainedEmptyOffload: Boolean = false, ) { /** Set the fresh-packet [rr] AND append the valid intervals onto the bounded [rrRecent] rolling * buffer (oldest fall off first). Non-positive sentinels are dropped from the rolling buffer. @@ -987,6 +992,9 @@ class WhoopBleClient( // #580: the 5/MG "history experimental" note is per-link — a fresh connect re-derives it // from the next offload, so it must not outlive the dropped link. historySyncExperimental = false, + // #612: the display flag only, not the underlying emptySyncTracker streak (that counter + // deliberately survives a reconnect, unchanged existing behaviour). + sustainedEmptyOffload = false, ) /** @@ -6408,6 +6416,9 @@ class WhoopBleClient( else -> futureClockBanner }, historySyncExperimental = whoop5HistoryExperimental, + // #612: set on EVERY HISTORY_COMPLETE (not just the empty branch) so a productive sync + // clears it immediately, exposing the streak previously reachable only via lastSyncError text. + sustainedEmptyOffload = sustainedEmpty, ) "timeout" -> it.copy( backfilling = false, diff --git a/android/app/src/main/java/com/noop/ui/TodayScoring.kt b/android/app/src/main/java/com/noop/ui/TodayScoring.kt index b638e07bf5..4ceadd555a 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -315,6 +315,12 @@ sealed class RecordingState { * experimental on 5.0. Surfaced from `LiveState.historySyncExperimental`, overriding the resolver. */ object HistoryExperimental : RecordingState() + /** #612, connected with no live HR AND no evidence data is actually flowing — either this is the + * strap's first-ever pairing (never once synced) or a WHOOP-4/generic strap whose last several + * offloads all came back empty ([LiveState.sustainedEmptyOffload]). Distinct from [NotRecording]: + * the link genuinely IS up, so claiming "Strap not connected" would be false. */ + object ConnectedNoData : RecordingState() + /** The chip's status word. VERBATIM, mirror Swift exactly. */ val title: String get() = when (this) { @@ -322,6 +328,7 @@ sealed class RecordingState { is LastSynced -> "Last synced ${minutesAgo}m ago" NotRecording -> "Not recording" HistoryExperimental -> "Connected" + ConnectedNoData -> "Connected" } /** The chip's one-line detail. VERBATIM, mirror Swift exactly. */ @@ -331,37 +338,44 @@ sealed class RecordingState { is LastSynced -> "Reconnect to pull the latest." NotRecording -> "Strap not connected. Tap to connect." HistoryExperimental -> "History sync is experimental on 5.0." + ConnectedNoData -> "No live heart rate or synced history yet this session." } /** Chip hue: live recording reads positive (gold/green dot), a stale-but-recent sync reads neutral, - * not-recording reads critical so a dropped link is obvious; the 5.0 experimental-history state is - * connected so it reads accent, not critical. */ + * not-recording reads critical so a dropped link is obvious; the 5.0 experimental-history state and + * the connected-no-data state are both connected so they read accent, not critical. */ val tone: StrandTone get() = when (this) { Recording -> StrandTone.Positive is LastSynced -> StrandTone.Neutral NotRecording -> StrandTone.Critical HistoryExperimental -> StrandTone.Accent + ConnectedNoData -> StrandTone.Accent } } /** * Resolve the honest [RecordingState] from the live BLE state + last-sync timestamp. Pure + unit-tested. * - connected AND a live HR is streaming -> [RecordingState.Recording] (it really is saving data); + * - connected, no live HR, AND (never synced OR [sustainedEmptyOffload]) -> [RecordingState.ConnectedNoData] + * (#612 — the link IS up, so "not connected" would be false); * - else a [lastSyncAtSec] this session -> [RecordingState.LastSynced] (minutes since, clamped >= 0, * ROUNDED UP so a 30s-old sync reads "1m ago" not "0m ago"); * - else -> [RecordingState.NotRecording]. * "Recording" requires BOTH a connection AND a live heart-rate sample so a bonded-but-silent link can't * claim it's saving data. [nowSec] is unix seconds (injected so the math is testable). Mirrors Swift - * `recordingStateFor`. + * `RecordingState.resolve`. */ internal fun recordingStateFor( connected: Boolean, liveHeartRate: Int?, lastSyncAtSec: Long?, nowSec: Long, + sustainedEmptyOffload: Boolean = false, ): RecordingState = when { connected && liveHeartRate != null -> RecordingState.Recording + connected && liveHeartRate == null && (lastSyncAtSec == null || sustainedEmptyOffload) -> + RecordingState.ConnectedNoData lastSyncAtSec != null -> { // Clamp at 0 (a sync stamped slightly in the future from strap-clock skew can't read negative) // then ROUND UP so a 30-second-old sync reads "1m ago", never "0m ago", matches the Swift diff --git a/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt b/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt index ad996e589c..1775926cc0 100644 --- a/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt +++ b/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt @@ -137,8 +137,34 @@ class TodayExplainabilityTest { @Test fun recording_notClaimed_whenConnectedButNoLiveHr() { - // A bonded-but-silent link must not claim it's saving data — falls to last-synced / not-recording. + // A bonded-but-silent link must not claim it's saving data. + // #612: connected + no live HR + never synced (a strap's first-ever pairing) must not claim + // "Not recording" either — the link genuinely IS up. Fixed from the prior `NotRecording` result, + // which was the exact bug #612 reported. val state = recordingStateFor(connected = true, liveHeartRate = null, lastSyncAtSec = null, nowSec = 1_000_000) + assertEquals(RecordingState.ConnectedNoData, state) + } + + @Test + fun connectedNoData_sustainedEmptyOffload_overridesAnOldLastSync() { + // #612: an established strap (old but known last-sync) whose recent offloads are a SUSTAINED + // empty streak reads "connected, no data" rather than a stale "Last synced 14d ago". + val now = 10_000L + val state = recordingStateFor( + connected = true, liveHeartRate = null, lastSyncAtSec = now - 1_209_600, nowSec = now, + sustainedEmptyOffload = true, + ) + assertEquals(RecordingState.ConnectedNoData, state) + } + + @Test + fun connectedNoData_sustainedEmptyOffload_withoutConnection_isNotConnectedNoData() { + // The `connected` gate still applies: a disconnected strap with a stale sustained-empty flag + // (carried over from before the drop) must not read "Connected". + val state = recordingStateFor( + connected = false, liveHeartRate = null, lastSyncAtSec = null, nowSec = 10_000L, + sustainedEmptyOffload = true, + ) assertEquals(RecordingState.NotRecording, state) }