Skip to content
Open
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
4 changes: 4 additions & 0 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4216,6 +4216,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// revision ever shifts these fields again, the rejection must be visible rather than silent.
if let pages = DataRange.pagesBehind(from: frame, cmdOff: cmdOff) {
log("Strap backlog pages behind: \(pages) (#689 — GET_DATA_RANGE ring backlog, diagnostic only)")
// #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally (unlike newest/oldest,
// which stay feedsSync-gated to WHOOP4 until a 5/MG capture confirms them too) — the sync chip's
// "N pages behind" detail reads this while `backfilling` is true.
state.setPagesBehindAtConnect(pages)
} else {
log("Strap backlog pages behind: not decodable from this frame (#689 — offsets may have moved; "
+ "the raw frame above is the input). Diagnostic only, sync is unaffected.")
Expand Down
15 changes: 15 additions & 0 deletions Strand/BLE/LiveState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ public final class LiveState: ObservableObject {
/// window can't outlive the link.
public func clearStrapRange() { strapRange = nil }

/// #689/#815: the strap's ring-buffer page backlog, sampled ONCE from the connect-time GET_DATA_RANGE
/// reply — never re-polled mid-offload (the link is already firmware-paced, ~10 records/s, #377). A
/// static "this many pages behind" figure, not a live percentage: the strap never reveals a total
/// pending-record count, only this bounded ring-buffer measure (write pointer − read pointer against a
/// 131072-page ring), confirmed against real captures on both WHOOP 4.0 and 5.0/MG. The sync chip
/// appends it to the chunk count while `backfilling` is true. nil before the first reply this session,
/// or if the frame didn't decode. Cleared on disconnect so a stale figure can't outlive the link.
@Published public private(set) var pagesBehindAtConnect: Int?

/// Bank the connect-time GET_DATA_RANGE pages-behind sample (see `pagesBehindAtConnect`).
public func setPagesBehindAtConnect(_ pages: Int) {
pagesBehindAtConnect = pages
}

@Published public var lastFrameType: String? = nil
@Published public var lastEvent: String? = nil
/// #987: unix of the most recent strap frame FrameRouter routed. Deliberately NOT @Published - the
Expand Down Expand Up @@ -492,6 +506,7 @@ public final class LiveState: ObservableObject {
recentHrSamples.removeAll() // Sleep readout buffers must not outlive the link (Group E)
recentGravitySamples.removeAll()
clearStrapRange() // a stale clock-drift window must not outlive the link either
pagesBehindAtConnect = nil // a stale pages-behind sample must not outlive the link either
lastFrameAtUnix = nil // #987: a stale "last frame" freshness must not outlive it either
ouraWearState = nil // a stale worn/charging badge must not outlive the link either
// Perf: flush the durable log tail on disconnect (mirroring is batched in `append`), so a completed
Expand Down
11 changes: 8 additions & 3 deletions Strand/Liquid/LiquidTodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1949,9 +1949,10 @@ private struct LiquidSyncChip: View {

var body: some View {
switch SyncChipState.resolve(live: live) {
case .syncing(let chunks):
case .syncing(let chunks, let pagesBehind):
pill(system: "arrow.triangle.2.circlepath", text: "\(chunks)",
a11y: String(localized: "Syncing strap history, \(chunks) chunks"))
detail: SyncChipState.pagesBehindDetail(pagesBehind),
a11y: SyncChipState.syncingAccessibilityLabel(chunks: chunks, pagesBehind: pagesBehind))
case .synced(let agoText):
pill(system: "checkmark", text: agoText,
a11y: String(localized: "Strap history synced \(agoText) ago"))
Expand All @@ -1963,10 +1964,14 @@ private struct LiquidSyncChip: View {
}
}

private func pill(system: String, text: String, a11y: String) -> some View {
private func pill(system: String, text: String, detail: String? = nil, a11y: String) -> some View {
HStack(spacing: 4) {
Image(systemName: system).font(.system(size: 11, weight: .bold))
Text(text).font(.system(size: 12, weight: .bold))
// #689/#815: the pages-behind detail, when known — see `SyncChipState.pagesBehindDetail`.
if let detail {
Text(detail).font(.system(size: 11, weight: .semibold)).foregroundStyle(.white.opacity(0.7))
}
}
.foregroundStyle(.white)
.padding(.horizontal, 10)
Expand Down
104 changes: 104 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -169628,6 +169628,110 @@
}
}
}
},
"%lld pages behind": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Seiten im Rückstand"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "%lld páginas de retraso"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "%lld pages de retard"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "%lld pagine di ritardo"
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "%lld páginas em atraso"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "%lld страниц позади"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "落后 %lld 页"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "落後 %lld 頁"
}
}
}
},
"Syncing strap history, %lld chunks, %lld pages behind": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks, %lld Seiten im Rückstand"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Sincronizando el historial de la pulsera, %lld bloques, %lld páginas de retraso"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Synchronisation de l'historique du bracelet, %lld blocs, %lld pages de retard"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Sincronizzazione della cronologia della fascia, %lld blocchi, %lld pagine di ritardo"
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "A sincronizar o histórico da bracelete, %lld pedaços, %lld páginas em atraso"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Синхронизация истории браслета, %lld фрагментов, %lld страниц позади"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "正在同步手环历史记录,%lld 个数据块,落后 %lld 页"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "正在同步手環歷史記錄,%lld 個資料塊,落後 %lld 頁"
}
}
}
}
},
"version": "1.0"
Expand Down
42 changes: 36 additions & 6 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4467,14 +4467,20 @@ struct TodayDayScopedCache {
/// yet) → `✓ live`. `.hidden` only on a true cold start (the building-scores note owns that case). Twin
/// of Android `SyncStatusChip`.
enum SyncChipState: Equatable {
case syncing(chunks: Int)
/// #689/#815 follow-up: `pagesBehind` is the strap's GET_DATA_RANGE ring backlog sampled once at
/// connect (`LiveState.pagesBehindAtConnect`) — nil until a reply has landed this session, or on a
/// device/frame where it didn't decode. Purely additive: nil renders identically to before this field
/// existed, so it defaults to the chunk-count-only chip.
case syncing(chunks: Int, pagesBehind: Int? = nil)
case synced(agoText: String)
case experimentalLive
case hidden

@MainActor
static func resolve(live: LiveState) -> SyncChipState {
if live.backfilling { return .syncing(chunks: live.syncChunksThisSession) }
if live.backfilling {
return .syncing(chunks: live.syncChunksThisSession, pagesBehind: live.pagesBehindAtConnect)
}
if let ts = live.lastSyncedAt { return .synced(agoText: shortAgo(ts)) }
if live.historySyncExperimental { return .experimentalLive }
return .hidden
Expand All @@ -4492,6 +4498,24 @@ enum SyncChipState: Equatable {
if hrs < 24 { return "\(hrs)h" }
return "\(hrs / 24)d"
}

/// The small trailing fragment the syncing chip appends beside the chunk count when a pages-behind
/// sample is known — "494 pages behind". nil (no fragment) when the sample hasn't landed yet, which
/// is exactly today's chunk-count-only rendering. Shared by `SyncStatusChip` and `LiquidSyncChip` so
/// the two headers can't word this differently.
static func pagesBehindDetail(_ pagesBehind: Int?) -> String? {
pagesBehind.map { String(localized: "\($0) pages behind") }
}

/// VoiceOver label for the syncing state. Extends the existing chunk-count sentence with the
/// pages-behind figure when known, so the detail reaches accessibility users the same way it reaches
/// sighted ones via `pagesBehindDetail`, rather than being a sighted-only visual fragment.
static func syncingAccessibilityLabel(chunks: Int, pagesBehind: Int?) -> String {
if let pagesBehind {
return String(localized: "Syncing strap history, \(chunks) chunks, \(pagesBehind) pages behind")
}
return String(localized: "Syncing strap history, \(chunks) chunks")
}
}

/// #245: a compact sync-status chip for the Today top bar, shown to EVERY user. The full-width
Expand All @@ -4504,9 +4528,10 @@ struct SyncStatusChip: View {

var body: some View {
switch SyncChipState.resolve(live: live) {
case .syncing(let chunks):
chip(system: "arrow.triangle.2.circlepath", text: "\(chunks)", tint: StrandPalette.accent,
a11y: String(localized: "Syncing strap history, \(chunks) chunks"))
case .syncing(let chunks, let pagesBehind):
chip(system: "arrow.triangle.2.circlepath", text: "\(chunks)",
detail: SyncChipState.pagesBehindDetail(pagesBehind), tint: StrandPalette.accent,
a11y: SyncChipState.syncingAccessibilityLabel(chunks: chunks, pagesBehind: pagesBehind))
case .synced(let agoText):
chip(system: "checkmark", text: agoText, tint: StrandPalette.textSecondary,
a11y: String(localized: "Strap history synced \(agoText) ago"))
Expand All @@ -4519,10 +4544,15 @@ struct SyncStatusChip: View {
}
}

private func chip(system: String, text: String, tint: Color, a11y: String) -> some View {
private func chip(system: String, text: String, detail: String? = nil, tint: Color, a11y: String) -> some View {
HStack(spacing: 4) {
Image(systemName: system).font(.system(size: 11, weight: .semibold))
Text(text).font(StrandFont.captionNumber)
// #689/#815: the pages-behind detail, when known. A trailing fragment rather than a redesign
// of this DRAFT chip — see `SyncChipState.pagesBehindDetail`.
if let detail {
Text(detail).font(StrandFont.caption).foregroundStyle(tint.opacity(0.7))
}
}
.foregroundStyle(tint)
.padding(.horizontal, 8)
Expand Down
12 changes: 11 additions & 1 deletion StrandTests/SyncChipStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ final class SyncChipStateTests: XCTestCase {
let live = LiveState()
live.backfilling = true
live.syncChunksThisSession = 7
XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7))
XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7, pagesBehind: nil))
}

/// #689/#815 follow-up: when a GET_DATA_RANGE sample has landed this session, it rides along on the
/// syncing state so the chip can append the "N pages behind" detail.
func testBackfilling_withPagesBehindSample_carriesItOnSyncingState() {
let live = LiveState()
live.backfilling = true
live.syncChunksThisSession = 7
live.setPagesBehindAtConnect(494)
XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7, pagesBehind: 494))
}

func testLastSyncedAt_isSyncedWithAgeText() {
Expand Down
13 changes: 13 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 @@ -208,6 +208,14 @@ 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,
/** #689/#815: the strap's ring-buffer page backlog, sampled ONCE from the connect-time GET_DATA_RANGE
* reply — never re-polled mid-offload (the link is already firmware-paced, ~10 records/s). A static
* "pages behind" figure, not a live percentage: the strap never reveals a total record count, only
* this bounded ring-buffer measure (write pointer − read pointer against a 131072-page ring),
* confirmed against real captures on both WHOOP 4.0 and 5.0/MG. The Today sync chip appends it to
* the chunk count while [backfilling] is true. null before the first reply this session, or if the
* frame didn't decode. Twin of macOS LiveState.pagesBehindAtConnect. */
val pagesBehindAtConnect: Int? = null,
) {
/** 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 @@ -973,6 +981,8 @@ 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,
// A stale pages-behind sample must not outlive the dropped link either (#689/#815).
pagesBehindAtConnect = null,
)

/**
Expand Down Expand Up @@ -4484,6 +4494,9 @@ class WhoopBleClient(
val pagesBehind = com.noop.protocol.DataRange.pagesBehind(frame, cmdOff)
if (pagesBehind != null) {
log("Strap backlog pages behind: $pagesBehind (#689 — GET_DATA_RANGE ring backlog, diagnostic only)")
// #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally — the
// Today sync chip's "N pages behind" detail reads this while backfilling is true.
_state.update { it.copy(pagesBehindAtConnect = pagesBehind.toInt()) }
} else {
log(
"Strap backlog pages behind: not decodable from this frame (#689 — offsets may " +
Expand Down
24 changes: 22 additions & 2 deletions android/app/src/main/java/com/noop/ui/TodayScoring.kt
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,11 @@ internal fun recordingStateFor(
* [Hidden] only on a true cold start (the building-scores note owns that case). Previously this
* priority order lived inline inside the `@Composable`, where it could not be unit-tested. */
sealed class SyncChipState {
data class Syncing(val chunks: Int) : SyncChipState()
/** #689/#815 follow-up: [pagesBehind] is the strap's GET_DATA_RANGE ring backlog sampled once at
* connect (`LiveState.pagesBehindAtConnect`) — null until a reply has landed this session, or on a
* device/frame where it didn't decode. Purely additive: null renders identically to before this
* field existed, so it defaults to the chunk-count-only chip. Mirrors Swift `SyncChipState.syncing`. */
data class Syncing(val chunks: Int, val pagesBehind: Int? = null) : SyncChipState()
data class Synced(val agoText: String) : SyncChipState()
object ExperimentalLive : SyncChipState()
object Hidden : SyncChipState()
Expand All @@ -394,8 +398,9 @@ sealed class SyncChipState {
chunks: Int,
lastSyncAtSec: Long?,
historySyncExperimental: Boolean,
pagesBehind: Int? = null,
): SyncChipState = when {
backfilling -> Syncing(chunks)
backfilling -> Syncing(chunks, pagesBehind)
lastSyncAtSec != null -> Synced(shortSyncAgo(lastSyncAtSec))
historySyncExperimental -> ExperimentalLive
else -> Hidden
Expand All @@ -416,6 +421,21 @@ internal fun shortSyncAgo(unixSec: Long): String {
}
}

/** #689/#815 follow-up: the small trailing fragment the syncing chip appends beside the chunk count when
* a pages-behind sample is known — "494 pages behind". null (no fragment) when the sample hasn't landed
* yet, which is exactly today's chunk-count-only rendering. Mirrors iOS `SyncChipState.pagesBehindDetail`. */
internal fun pagesBehindDetail(pagesBehind: Int?): String? =
pagesBehind?.let { uiString(R.string.l10n_today_screen_sync_chip_pages_behind_29b5c548, it) }

/** Accessibility/content-description label for the syncing state. Extends the existing chunk-count
* sentence with the pages-behind figure when known. Mirrors iOS `SyncChipState.syncingAccessibilityLabel`. */
internal fun syncingAccessibilityLabel(chunks: Int, pagesBehind: Int?): String =
if (pagesBehind != null) {
uiString(R.string.l10n_today_screen_sync_chip_syncing_pages_desc_6c2a5ea9, chunks, pagesBehind)
} else {
uiString(R.string.l10n_today_screen_sync_chip_syncing_desc_bfc290e7, chunks)
}

/** Whether this night's sleep staging is low-confidence, using the core [ScoreConfidence] rule. */
internal fun restStageLowConfidence(d: DailyMetric?): Boolean {
val asleepMin = d?.totalSleepMin ?: return false
Expand Down
Loading