Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions Strand/BLE/LiveState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 22 additions & 5 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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"
}
}

Expand All @@ -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."
}
}

Expand All @@ -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))
Expand Down
32 changes: 29 additions & 3 deletions StrandTests/TodayExplainabilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)

/**
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 17 additions & 3 deletions android/app/src/main/java/com/noop/ui/TodayScoring.kt
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,20 @@ 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) {
Recording -> "Recording"
is LastSynced -> "Last synced ${minutesAgo}m ago"
NotRecording -> "Not recording"
HistoryExperimental -> "Connected"
ConnectedNoData -> "Connected"
}

/** The chip's one-line detail. VERBATIM, mirror Swift exactly. */
Expand All @@ -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
Expand Down
Loading