From a9c4be7b6d63453f257d6be51c8ec8f87947b055 Mon Sep 17 00:00:00 2001 From: digitalerdude Date: Sat, 12 Sep 2026 11:08:03 +0200 Subject: [PATCH 1/2] Surface the strap backlog on the sync chip, with both counts inflected (#833 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on current main rather than rebased: the chip has moved since the original branch (#859 made its state resolvable without an Application, #1472 changed the age token, #1208 fixed its height), and the old diff no longer described the code it was patching. ## Plurals, the substantive half of the review "1 pages behind" was the reported defect, but the accessibility line carries TWO counts and Android inflects one quantity per resource. So each count becomes its own and the sentence becomes a template joining them. The joiner sits inside the template, never on a resource edge — aapt2 strips edge whitespace, which is what #2041 had to clean up across 59 sites. That also fixes "1 chunks", which predates this change and was never right in any locale. Seven locales, not the five the original branch knew: pl and ru landed since July. Both take the CLDR four-form set; zh takes `other` alone. NOT verified by a native speaker — the forms follow CLDR and the existing plural entries in each file, and that is the extent of the claim. ## A zero backlog is dropped, not rendered "0 pages behind at connect" beside a spinning sync icon contradicts itself, and a zero sample gives a reader nothing to act on. The rule lives in `resolve` on both platforms so neither view layer decides it alone, and it is pinned by a test. ## The figure says "at connect", per the review's second point It is sampled once from the connect-time GET_DATA_RANGE reply and never re-polled (#377), so a static number under a "syncing" label otherwise reads as a stalled live one. Co-Authored-By: Claude Opus 5 --- Strand/BLE/BLEManager.swift | 6 +++ Strand/BLE/LiveState.swift | 10 ++++ Strand/Screens/DevicesView.swift | 2 +- Strand/Screens/TodayView.swift | 16 +++++- StrandTests/SyncChipStateTests.swift | 4 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 15 ++++++ .../src/main/java/com/noop/ui/TodayScoring.kt | 14 +++++- .../src/main/java/com/noop/ui/TodayScreen.kt | 47 ++++++++++++++++-- .../app/src/main/res/values-de/strings.xml | 11 ++++- .../app/src/main/res/values-es/strings.xml | 11 ++++- .../app/src/main/res/values-fr/strings.xml | 11 ++++- .../app/src/main/res/values-pl/strings.xml | 15 +++++- .../src/main/res/values-pt-rPT/strings.xml | 11 ++++- .../app/src/main/res/values-ru/strings.xml | 15 +++++- .../app/src/main/res/values-zh/strings.xml | 9 +++- android/app/src/main/res/values/strings.xml | 11 ++++- .../java/com/noop/ui/SyncChipStateTest.kt | 49 +++++++++++++++++++ 17 files changed, 238 insertions(+), 19 deletions(-) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index b7ce6b914b..f278b32f99 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -5927,6 +5927,9 @@ 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 + // #689/#815: the backlog sample is "at connect" by definition, so it must not survive the link it + // was taken on — a stale figure under a fresh connection would be a plain lie. + state.pagesBehindAtConnect = nil lastBatteryReadAt = nil // #battery: next connect's first enableLiveNotifications re-seeds the 5/MG battery reading // #612: the display flag only, not the underlying emptySyncTracker streak (that counter // deliberately survives a reconnect — unchanged, existing behaviour). A fresh link re-derives @@ -6719,6 +6722,9 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { if !DataRange.isPendingResponse(frame, cmdOff: cmdOff) { 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. The Today sync + // chip reads this while backfilling is true. Twin of the Android FrameRouter branch. + state.pagesBehindAtConnect = Int(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.") diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index 99258b531c..92cfd508a0 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -381,6 +381,16 @@ 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 + /// #689/#815 — the strap's ring-buffer page backlog, sampled ONCE from the connect-time + /// GET_DATA_RANGE reply and never re-polled mid-offload: the link is already firmware-paced, and #377 + /// rules out re-polling just to feed a readout. So this is a figure AT CONNECT rather than a live one, + /// and the Today sync chip's copy says so — a static number under a "syncing" label otherwise reads as + /// a stalled live one. A bounded ring measure (write pointer − read pointer against the ring size), + /// never a percentage: the strap never reveals a total record count. Confirmed against real captures + /// on WHOOP 4.0 and 5.0/MG. nil before the first reply this session, or when the frame did not decode. + /// Twin of Android `LiveState.pagesBehindAtConnect`. + @Published public var pagesBehindAtConnect: Int? = nil + /// #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 diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index d5d0432e23..12743f287e 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -641,7 +641,7 @@ private struct DeviceSyncStatusCard: View { var body: some View { switch SyncChipState.resolve(live: live) { - case .syncing(let chunks): + case .syncing(let chunks, _): statusCard( systemImage: "arrow.triangle.2.circlepath", detail: chunks > 0 diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 62d1e5967c..d7f32d6c92 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -5543,14 +5543,26 @@ 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`) and never re-polled, so the copy reports it "at + /// connect" rather than as a live figure. nil when no reply has landed this session, when the frame + /// did not decode, AND when the backlog is zero: a chip that is actively syncing while claiming + /// "0 pages behind" contradicts itself, and a zero sample carries nothing a reader can act on. + /// `resolve` applies that rule so both platforms drop the same case. Twin of Android + /// `SyncChipState.Syncing`. + case syncing(chunks: Int, pagesBehind: Int?) 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 { + // The zero rule above. Negative cannot come off the wire (the decoder returns a ring delta), + // but the bound reads the same either way. Android spells this `?.takeIf { it > 0 }`. + return .syncing(chunks: live.syncChunksThisSession, + pagesBehind: live.pagesBehindAtConnect.flatMap { $0 > 0 ? $0 : nil }) + } if let ts = live.lastSyncedAt { return .synced(agoText: shortAgo(ts)) } if live.historySyncExperimental { return .experimentalLive } return .hidden diff --git a/StrandTests/SyncChipStateTests.swift b/StrandTests/SyncChipStateTests.swift index 9b35a2919f..2c71498022 100644 --- a/StrandTests/SyncChipStateTests.swift +++ b/StrandTests/SyncChipStateTests.swift @@ -15,7 +15,7 @@ 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)) } func testLastSyncedAt_isSyncedWithAgeText() { @@ -50,7 +50,7 @@ final class SyncChipStateTests: XCTestCase { live.backfilling = true live.syncChunksThisSession = 2 live.lastSyncedAt = Date().timeIntervalSince1970 - 5 - XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 2)) + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 2, pagesBehind: nil)) } func testLastSyncedAt_takesPriorityOverHistorySyncExperimental() { 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 7ac97d54e4..934a428a7f 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -287,6 +287,15 @@ 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 and never re-polled mid-offload — the link is already firmware-paced, and + * #377 rules out re-polling for a readout. So this is a figure AT CONNECT, not a live one, and the + * Today sync chip's copy says so rather than letting a static number read as a stalled live one. + * A bounded ring measure (write pointer − read pointer against the ring size), never a percentage: + * the strap never reveals a total record count. Confirmed against real captures on WHOOP 4.0 and + * 5.0/MG. null before the first reply this session, or when the frame did not decode. Twin of + * macOS LiveState.pagesBehindAtConnect. */ + val pagesBehindAtConnect: Int? = null, /** #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. @@ -1371,6 +1380,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, + // #689/#815: the backlog sample is "at connect" by definition, so it must not survive the + // link it was taken on — a stale figure under a fresh connection would be a plain lie. + pagesBehindAtConnect = null, // #612: the display flag only, not the underlying emptySyncTracker streak (that counter // deliberately survives a reconnect, unchanged existing behaviour). sustainedEmptyOffload = false, @@ -7586,6 +7598,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 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 " + 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 566886025a..0365a2a7d7 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -416,7 +416,13 @@ 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`) and never re-polled, so it is a figure "at connect" + * rather than a live one — the copy says so. null when no reply has landed this session, when the + * frame did not decode, AND when the backlog is zero: a chip that is actively syncing while + * claiming "0 pages behind" contradicts itself, and a zero sample carries nothing a reader can + * act on. [resolve] applies that rule so both platforms drop the same case. */ + data class Syncing(val chunks: Int, val pagesBehind: Int? = null) : SyncChipState() data class Synced(val agoText: String) : SyncChipState() object ExperimentalLive : SyncChipState() object Hidden : SyncChipState() @@ -439,8 +445,12 @@ sealed class SyncChipState { lastSyncAtSec: Long?, historySyncExperimental: Boolean, nowSec: Long, + pagesBehind: Int? = null, ): SyncChipState = when { - backfilling -> Syncing(chunks) + // `takeIf { it > 0 }` is the zero rule from [Syncing.pagesBehind], applied here so the + // decision is pure and testable rather than sitting in the composable. Negative can't come + // off the wire (the decoder returns a ring delta), but the bound reads the same either way. + backfilling -> Syncing(chunks, pagesBehind?.takeIf { it > 0 }) lastSyncAtSec != null -> Synced(shortSyncAgo(lastSyncAtSec, nowSec)) historySyncExperimental -> ExperimentalLive else -> Hidden diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 6d3443a2d5..be0fdded18 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -263,6 +263,9 @@ private data class TodayLiveSnapshot( * most once per connection, so it costs the snapshot nothing. */ val historyReady: Boolean, val historySyncExperimental: Boolean, + /** #689/#815: the connect-time GET_DATA_RANGE backlog sample, when known. Set once per connection, + * so it adds no per-tick churn to this snapshot. */ + val pagesBehindAtConnect: Int?, val batteryPct: Double?, /** True once a WHOOP 5/MG strap has been seen this session, picks the 5/MG rated-life fallback for the * battery runtime estimate (#713). Changes at most once per connection, so it doesn't reintroduce the @@ -347,6 +350,7 @@ fun TodayScreen( syncChunksThisSession = s.syncChunksThisSession, historyReady = s.historyReady, historySyncExperimental = s.historySyncExperimental, + pagesBehindAtConnect = s.pagesBehindAtConnect, batteryPct = s.batteryPct, whoop5 = s.whoop5Detected, charging = s.charging, @@ -1338,6 +1342,7 @@ fun TodayScreen( syncChunksThisSession = liveSnap.syncChunksThisSession, lastSyncAt = liveSnap.lastSyncAt, historySyncExperimental = liveSnap.historySyncExperimental, + pagesBehindAtConnect = liveSnap.pagesBehindAtConnect, onPickDay = { offset -> selectedDayOffset = offset }, onQuickActions = onQuickActions, onOpenSettings = onOpenSettings, @@ -2355,6 +2360,8 @@ private fun LiquidTodayHeader( syncChunksThisSession: Int = 0, lastSyncAt: Long? = null, historySyncExperimental: Boolean = false, + // #689/#815: the connect-time backlog sample, when known. + pagesBehindAtConnect: Int? = null, onPickDay: (Int) -> Unit, onQuickActions: () -> Unit, onOpenSettings: () -> Unit, @@ -2452,6 +2459,7 @@ private fun LiquidTodayHeader( SyncStatusChip( backfilling = backfilling, chunks = syncChunksThisSession, lastSyncAt = lastSyncAt, historySyncExperimental = historySyncExperimental, + pagesBehind = pagesBehindAtConnect, ) // (a) Profile avatar (the photo set in Settings, or the NOOP loop mark) → Settings. Mirrors iOS. Box( @@ -2490,6 +2498,7 @@ private fun SyncStatusChip( chunks: Int, lastSyncAt: Long?, historySyncExperimental: Boolean, + pagesBehind: Int? = null, ) { // The clock and the translated "now" word are resolved HERE, in the composable that already depends // on both, and handed down — so `SyncChipState.resolve` stays a genuinely pure decision that a plain @@ -2501,11 +2510,28 @@ private fun SyncStatusChip( lastSyncAtSec = lastSyncAt, historySyncExperimental = historySyncExperimental, nowSec = System.currentTimeMillis() / 1000L, + pagesBehind = pagesBehind, ) when (state) { - is SyncChipState.Syncing -> ChipCapsule( - Icons.Filled.Autorenew, "${state.chunks}", Palette.accent, - uiString(R.string.l10n_today_screen_sync_chip_syncing_desc_bfc290e7, state.chunks)) + is SyncChipState.Syncing -> { + // Both counts are inflected, so "1 chunk" and "1 page" read correctly. Android + // inflects one quantity per resource, so each count resolves separately and a template + // sentence joins them — the joiner sits inside the template, never on a resource edge, + // because aapt2 strips edge whitespace (#2041). + val chunksText = uiPlural(R.plurals.sync_chip_chunks_count, state.chunks, state.chunks) + val pagesText = state.pagesBehind?.let { + uiPlural(R.plurals.sync_chip_pages_behind_count, it, it) + } + ChipCapsule( + Icons.Filled.Autorenew, "${state.chunks}", Palette.accent, + desc = if (pagesText != null) { + uiString(R.string.l10n_today_screen_sync_chip_syncing_pages_desc_ad763b39, chunksText, pagesText) + } else { + uiString(R.string.l10n_today_screen_sync_chip_syncing_desc_92daf60c, chunksText) + }, + detail = pagesText, + ) + } is SyncChipState.Synced -> ChipCapsule( Icons.Filled.Check, state.agoText, Palette.textSecondary, uiString(R.string.l10n_today_screen_sync_chip_synced_desc_4d255944, state.agoText)) @@ -2519,7 +2545,7 @@ private fun SyncStatusChip( /** The shared sync-chip capsule (icon + terse label). Twin of the iOS `SyncStatusChip.chip`. */ @Composable -private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: String) { +private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: String, detail: String? = null) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -2533,6 +2559,19 @@ private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: Stri ) { Icon(icon, contentDescription = desc, tint = tint, modifier = Modifier.size(14.dp)) Text(text, style = NoopType.caption, color = tint) + // #689/#815: the optional secondary line (the backlog figure). Dimmer than the count it follows, + // because the count is the live thing and this is a fixed sample taken at connect. maxLines = 1 + // with ellipsis so a long localisation cannot push the header cluster off a narrow screen + // (#1835); the accessibility description carries the full sentence either way. + if (detail != null) { + Text( + detail, + style = NoopType.caption, + color = Palette.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index fecfe61bee..1eac6cfd7a 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1715,7 +1715,16 @@ Erholung jetzt Live - Verlaufssynchronisierung des Straps läuft, %1$d Chunks + Verlaufssynchronisierung des Straps läuft, %1$s + Verlaufssynchronisierung des Straps läuft, %1$s, %2$s + + %1$d Chunk + %1$d Chunks + + + %1$d Seite Rückstand bei Verbindungsaufbau + %1$d Seiten Rückstand bei Verbindungsaufbau + Verlauf des Straps vor %1$s synchronisiert Verbunden; die Verlaufssynchronisierung ist bei diesem Strap experimentell. %1$s%% Ladung diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 3526edf6db..b8b8746eea 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1515,7 +1515,16 @@ Recuperación ahora en vivo - Sincronizando el historial de la pulsera, %1$d bloques + Sincronizando el historial de la pulsera, %1$s + Sincronizando el historial de la pulsera, %1$s, %2$s + + %1$d bloque + %1$d bloques + + + %1$d página de retraso al conectar + %1$d páginas de retraso al conectar + Historial de la pulsera sincronizado hace %1$s Conectado; la sincronización del historial es experimental en esta pulsera. %1$s%% diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 481e022779..2e0e15939c 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1533,7 +1533,16 @@ Récupération à l\'instant en direct - Synchronisation de l\'historique du bracelet, %1$d blocs + Synchronisation de l\'historique du bracelet, %1$s + Synchronisation de l\'historique du bracelet, %1$s, %2$s + + %1$d bloc + %1$d blocs + + + %1$d page de retard à la connexion + %1$d pages de retard à la connexion + Historique du bracelet synchronisé il y a %1$s Connecté ; la synchronisation de l\'historique est expérimentale sur ce bracelet. Charge %1$s%% diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 851df1c5c1..829b31bb90 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -1672,7 +1672,20 @@ Regeneracja Teraz na żywo - Synchronizowanie historii pasków, fragmenty %1$d + Synchronizowanie historii paska, %1$s + Synchronizowanie historii paska, %1$s, %2$s + + %1$d fragment + %1$d fragmenty + %1$d fragmentów + %1$d fragmentu + + + %1$d strona zaległości przy połączeniu + %1$d strony zaległości przy połączeniu + %1$d stron zaległości przy połączeniu + %1$d strony zaległości przy połączeniu + Historia paska zsynchronizowana %1$s temu Połączony; Synchronizacja historii paska jest eksperymentalna w przypadku tego paska %1$s%% Energia diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 68baedd659..e4e9d7d043 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1678,7 +1678,16 @@ Recuperação agora em direto - A sincronizar o histórico da bracelete, %1$d pedaços + A sincronizar o histórico da bracelete, %1$s + A sincronizar o histórico da bracelete, %1$s, %2$s + + %1$d pedaço + %1$d pedaços + + + %1$d página em atraso na ligação + %1$d páginas em atraso na ligação + Histórico da bracelete sincronizado há %1$s Ligada; a sincronização do histórico é experimental nesta bracelete. %1$s%% Carga diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 2127b658c6..f9c9291ccc 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -1723,7 +1723,20 @@ Восстановление сейчас в реальном времени - Синхронизация истории браслета, %1$d фрагментов + Синхронизация истории браслета, %1$s + Синхронизация истории браслета, %1$s, %2$s + + %1$d фрагмент + %1$d фрагмента + %1$d фрагментов + %1$d фрагмента + + + %1$d страница отставания при подключении + %1$d страницы отставания при подключении + %1$d страниц отставания при подключении + %1$d страницы отставания при подключении + История браслета синхронизирована %1$s назад Подключено; синхронизация истории экспериментальна для этого браслета. %1$s%% Заряда diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index df15601682..a47d24f015 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1655,7 +1655,14 @@ 恢复分数 刚刚 实时 - 正在同步腕带历史记录,%1$d 个数据块 + 正在同步腕带历史记录,%1$s + 正在同步腕带历史记录,%1$s,%2$s + + %1$d 个数据块 + + + 连接时落后 %1$d 页 + 手环历史记录已在 %1$s 前同步 已连接;此手环的历史同步功能为实验性功能。 %1$s%% 充能(Charge) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cca34ba3d3..05b2f75469 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1820,7 +1820,16 @@ Recovery now live - Syncing strap history, %1$d chunks + Syncing strap history, %1$s + Syncing strap history, %1$s, %2$s + + %1$d chunk + %1$d chunks + + + %1$d page behind at connect + %1$d pages behind at connect + Strap history synced %1$s ago Connected; strap history sync is experimental on this strap %1$s%% Charge diff --git a/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt b/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt index ae3f2c5ebe..403a4abe10 100644 --- a/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt +++ b/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt @@ -89,4 +89,53 @@ class SyncChipStateTest { ) assertEquals(SyncChipState.Synced("<1m"), state) } + + // #689/#815: the connect-time backlog sample. Twin of the iOS cases in `SyncChipStateTests`. + + @Test + fun backfillingWithBacklog_carriesThePagesFigure() { + val state = SyncChipState.resolve( + backfilling = true, chunks = 3, lastSyncAtSec = null, historySyncExperimental = false, + nowSec = NOW, pagesBehind = 120, + ) + assertEquals(SyncChipState.Syncing(3, 120), state) + } + + /** + * A zero backlog is dropped rather than rendered. The chip only exists while a sync is running, so + * "0 pages behind at connect" next to a spinning icon contradicts itself, and a zero sample gives a + * reader nothing to act on. Pinned because the rule lives in `resolve` precisely so both platforms + * drop the same case rather than each deciding in its own view layer. + */ + @Test + fun backfillingWithZeroBacklog_dropsTheDetail() { + val state = SyncChipState.resolve( + backfilling = true, chunks = 3, lastSyncAtSec = null, historySyncExperimental = false, + nowSec = NOW, pagesBehind = 0, + ) + assertEquals(SyncChipState.Syncing(3, null), state) + } + + /** No reply yet this session, or a frame that did not decode: the chip is exactly what it was before. */ + @Test + fun backfillingWithoutASample_isUnchanged() { + val state = SyncChipState.resolve( + backfilling = true, chunks = 3, lastSyncAtSec = null, historySyncExperimental = false, + nowSec = NOW, pagesBehind = null, + ) + assertEquals(SyncChipState.Syncing(3, null), state) + } + + /** + * The backlog only qualifies an in-progress sync. Once the strap has finished, the chip reports when + * it last synced, and a stale "behind" figure must not survive into that state. + */ + @Test + fun notBackfilling_ignoresTheBacklog() { + val state = SyncChipState.resolve( + backfilling = false, chunks = 0, lastSyncAtSec = NOW - 65, historySyncExperimental = false, + nowSec = NOW, pagesBehind = 120, + ) + assertEquals(SyncChipState.Synced("1m"), state) + } } From a2aa0a59b10096ec7c2927baa21cf4e3ec7d8cad Mon Sep 17 00:00:00 2001 From: digitalerdude Date: Sat, 12 Sep 2026 11:18:25 +0200 Subject: [PATCH 2/2] Apple half: inflect both counts and carry the backlog into the sync read-out (#833 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the Android half in the previous commit, so the parity contract holds in one PR. The chunk phrase already had plural variations in 9 of 10 catalogue locales — pl was still a flat string and is now inflected like the rest. The backlog phrase is new, inflected across all ten, and a template joins the two so both counts read correctly and the joining punctuation stays inside the translated string. The template's wording is DERIVED from each locale's existing chunk translation rather than re-translated, so nothing already reviewed changes meaning. Where it surfaces differs by platform, deliberately. Android's chip is a capsule with room for a second text run, so the figure is visible there. Apple's is a spinner with the count inside it and no room for a phrase, so the figure lands in the accessibility read-out that already spoke the chunk count. Feature-level parity, not pixel parity. The zero rule and the "at connect" wording match the Kotlin twin exactly, and `resolve` applies the zero rule on both sides so neither view layer decides it alone. Co-Authored-By: Claude Opus 5 --- Strand/Liquid/LiquidTodayView.swift | 16 +- Strand/Resources/Localizable.xcstrings | 424 ++++++++++++++++++++++++- StrandTests/SyncChipStateTests.swift | 42 +++ 3 files changed, 476 insertions(+), 6 deletions(-) diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index f491def734..a5f71164eb 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -2735,9 +2735,19 @@ private struct LiquidBatteryButton: View { // VoiceOver announcing a real count while the ring showed the synthetic one — i.e. the // harness could not be used to check the read-out it exists to exercise. let n = syncChunks - return n > 0 - ? String(localized: "Syncing strap history, \(n) chunks") - : String(localized: "Syncing strap history") + guard n > 0 else { return String(localized: "Syncing strap history") } + // #689/#815: the connect-time ring backlog, when the strap reported one. Zero is dropped by + // `SyncChipState.resolve`, and dropped here for the same reason: "0 pages behind" beside a + // running sync contradicts itself. Both counts inflect — the phrase is built from its own + // entry and joined through a template, so "1 chunk" and "1 page" read correctly and the + // joining punctuation stays inside the translated template rather than being concatenated. + let behind = live.pagesBehindAtConnect + .flatMap { $0 > 0 ? $0 : nil } + .map { String(localized: "\($0) pages behind at connect") } + if let behind { + return String(localized: "Syncing strap history, \(n) chunks, \(behind)") + } + return String(localized: "Syncing strap history, \(n) chunks") } switch batteryDisplay { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index e0d704868d..ff1c267adc 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -155462,9 +155462,427 @@ } }, "pl": { - "stringUnit": { - "state": "translated", - "value": "Synchronizowanie historii pasków, fragmenty %lld" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragment" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmenty" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentów" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentu" + } + } + } + } + } + } + }, + "%lld pages behind at connect": { + "extractionState": "manual", + "localizations": { + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Seite Rückstand bei Verbindungsaufbau" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Seiten Rückstand bei Verbindungsaufbau" + } + } + } + } + }, + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld page behind at connect" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pages behind at connect" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld página de retraso al conectar" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas de retraso al conectar" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld page de retard à la connexion" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pages de retard à la connexion" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld pagina di ritardo alla connessione" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pagine di ritardo alla connessione" + } + } + } + } + }, + "pl": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld strona zaległości przy połączeniu" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld strony zaległości przy połączeniu" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld stron zaległości przy połączeniu" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld strony zaległości przy połączeniu" + } + } + } + } + }, + "pt-PT": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld página em atraso na ligação" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas em atraso na ligação" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld страница отставания при подключении" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld страницы отставания при подключении" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld страниц отставания при подключении" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld страницы отставания при подключении" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "连接时落后 %lld 页" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "連線時落後 %lld 頁" + } + } + } + } + } + } + }, + "Syncing strap history, %lld chunks, %@": { + "extractionState": "manual", + "localizations": { + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunk, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks, %@" + } + } + } + } + }, + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunk, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunks, %@" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloque, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloques, %@" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld bloc, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld blocs, %@" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocco, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocchi, %@" + } + } + } + } + }, + "pl": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragment, %@" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmenty, %@" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentów, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentu, %@" + } + } + } + } + }, + "pt-PT": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaço, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaços, %@" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмент, %@" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента, %@" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагментов, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента, %@" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手环历史记录,%lld 个数据块,%@" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手環歷史記錄,%lld 個資料塊,%@" + } + } + } } } } diff --git a/StrandTests/SyncChipStateTests.swift b/StrandTests/SyncChipStateTests.swift index 2c71498022..39b88a935a 100644 --- a/StrandTests/SyncChipStateTests.swift +++ b/StrandTests/SyncChipStateTests.swift @@ -63,4 +63,46 @@ final class SyncChipStateTests: XCTestCase { XCTFail("A known last-sync should win over the experimental fallback") } } + + // #689/#815: the connect-time backlog sample. Twin of the Android cases in `SyncChipStateTest`. + + @MainActor + func testBackfillingWithBacklogCarriesThePagesFigure() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + live.pagesBehindAtConnect = 120 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: 120)) + } + + /// A zero backlog is dropped rather than rendered: "0 pages behind at connect" beside a running + /// sync contradicts itself, and a zero sample gives a reader nothing to act on. The rule lives in + /// `resolve` on both platforms so neither view layer decides it alone. + @MainActor + func testBackfillingWithZeroBacklogDropsTheDetail() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + live.pagesBehindAtConnect = 0 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: nil)) + } + + /// No reply yet this session, or a frame that did not decode. + @MainActor + func testBackfillingWithoutASampleIsUnchanged() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: nil)) + } + + /// The backlog only qualifies an in-progress sync; a stale figure must not survive into `.synced`. + @MainActor + func testNotBackfillingIgnoresTheBacklog() { + let live = LiveState() + live.backfilling = false + live.lastSyncedAt = Date().timeIntervalSince1970 - 65 + live.pagesBehindAtConnect = 120 + XCTAssertEqual(SyncChipState.resolve(live: live), .synced(agoText: "1m")) + } }