Add a sync-status chip to the Liquid Today header (#245) - #806
Conversation
The v8 Liquid redesign shipped with no backfill/sync indication at all in its always-visible header — only the classic Today header's SyncStatusChip had one, and Android already places its own equivalent next to the battery ring. On a WHOOP 5.0, whose history offloads are rare, Liquid users had no way to see a sync ever happening except drilling into the collapsed Data Sources card. Extracts the state-picking logic already in SyncStatusChip into a shared, @mainactor SyncChipState.resolve(live:), so the classic and Liquid headers render from the exact same three-state decision (syncing / synced / 5.0 experimental / hidden) and can't drift on when to show what. Adds LiquidSyncChip next to LiquidBatteryButton, restyled to the header's own dark-hero icon idiom (.white.opacity(0.16) fill) instead of reusing SyncStatusChip's light-surface chrome, which would read poorly over the photo/gradient hero. No Android change needed — it already has this. No byte/percentage progress added: the WHOOP protocol doesn't expose a total pending-record count, so a chunk counter remains the honest ceiling (per syncChunksThisSession's own doc comment). Verified: xcodebuild build for both Strand (macOS) and NOOPiOS, plus a new SyncChipStateTests covering the state-priority order and the cold-start hidden case (6/6 passing). Visual confirmation of the chip's three states on a live strap session is still needed on real hardware before merging.
|
Reviewed (draft, so flagging what to settle before it leaves that state). The extraction is behaviour-preserving. Android claim checks out — Declining the byte/percentage progress is correct, and for the documented reason rather than convenience: the protocol never reveals a total pending-record count, so a percentage would be invented. Chunks are the honest ceiling. One finding: the chip's text isn't localized, on either header.
So "live", "now" and all three VoiceOver labels are hard-coded English for every locale. This is pre-existing in the classic chip rather than introduced here — but this PR duplicates the pattern into a second header, and the i18n gate can't see it (it passed): the audit looks for catalog keys, and a bare Worth fixing while the code is open, since it's small — take Not blocking, and the rest is clean. Your own note says the three states haven't been seen on a real strap session yet — that plus the localization would be my bar for taking it out of draft. |
|
Checked the Android twin properly rather than just confirming it exists. Behaviour parity holds exactly. Test parity is what this PR changes. iOS gains That's worth closing while the shape is fresh, and the repo already has the pattern: And the localization gap is both platforms, not just iOS. Android's chip renders raw literals too — Neither is blocking, and neither is a regression from this PR — but the shared-state extraction is exactly the moment where making the Kotlin side match costs least. |
|
With #818 I think we can trust the pagesBehind metric - is that useful for calculating a percentage? Are chunks + pages the same, or can we get page progress somehow? |
|
@jbergler good question, and #818 does change the picture — but there's an obstacle in the way that isn't about the metric. Pages ≠ chunks. A page is a ring-buffer slot on the strap: But pages are the better denominator anyway, if you can get them twice. From #818's four fixtures the values are 83, 2, 8 and 494 pages behind — that's a real, bounded backlog measure, exactly the shape a progress bar wants. The obstacle: we only ask once. Making it work needs re-polling So I'd revise my earlier "declining percentage progress is correct" to "correct today, and newly worth revisiting". The honest intermediate is what the metric already gives you for free: a pages remaining readout rather than a percentage. One sample, no re-polling, no risk to the offload — and it turns "Syncing…" into "Syncing — 494 pages behind", which is most of the user value without any of the uncertainty. Worth noting #821 also landed since: when the decode fails, the strap log now says so rather than silently logging nothing, which is what let the broken offsets sit unnoticed. So if a future firmware moves those fields, a progress UI built on it would have a visible failure mode instead of a mysteriously stuck bar. |
…anbr#245 review) Two findings from PR review: 1. The chip's display text and VoiceOver labels were plain Strings, not LocalizedStringKey — string literals localize in SwiftUI, String variables don't, so "live"/"now" and the accessibility labels were hardcoded English on every locale, on both the classic and the new Liquid chip. Wraps each in String(localized:) and adds the five catalog entries (de/es/fr/it/pt-PT/ru/ zh-Hans/zh-Hant) to Strand/Resources/Localizable.xcstrings — shared by both the Strand and NOOPiOS targets, so one catalog edit covers both. 2. Android's SyncStatusChip had the same untranslated literals (pre-existing since ryanbr#245's original chip, not introduced here) AND kept its state-picking priority order inline inside the @composable, unlike the newly-extracted, unit-tested SyncChipState on the Swift side — so the two twins could drift silently. Extracts an equivalent SyncChipState sealed class + shortSyncAgo into TodayScoring.kt (same four cases, same priority order), wires SyncStatusChip/ChipCapsule through uiString(R.string...) for the same five strings across Android's five shipped locales, and adds SyncChipStateTest mirroring the six iOS SyncChipStateTests cases. Verified: xcodebuild build for Strand (macOS) and NOOPiOS, StrandTests SyncChipStateTests (6/6), Tools/i18n_audit.py --ci origin/main clean on both platforms, and `./gradlew compileFullDebugKotlin`. Android's testFullDebugUnitTest could not be run locally — an unrelated, pre-existing gap (three FakeRegistryDao test fakes are missing ~25 methods DeviceRegistryDao now declares) fails test compilation for the whole module regardless of this change; filed separately.
|
Addressed both findings in 8d49c38:
One thing surfaced while verifying: Android's Left the third thread (the pages-behind/#818 discussion) alone — that's a new feature proposal, not a fix to what's in this PR, and better scoped as its own follow-up. |
|
Merged — thanks. The extraction is the right shape: shared decision, separate chrome, and six tests on logic that had none. Two things I asked for in review that I've withdrawn, both mine rather than yours: Localizing the chip strings. Pre-existing, not introduced here — Extracting Android's Worth knowing for anyone reading later: the i18n gate can't see this class of issue at all. The chip renders |
…an Application (#859) SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySyncExperimental fails on main — the only failure in the suite, 3060 tests, 1 failure — with: java.lang.IllegalStateException: NoopApplication is not attached at com.noop.NoopApplication$Companion.localizedString(NoopApplication.kt:134) at com.noop.ui.UiStringsKt.uiString(UiStrings.kt:16) at com.noop.ui.TodayScoringKt.shortSyncAgo(TodayScoring.kt:412) at com.noop.ui.SyncChipState$Companion.resolve(TodayScoring.kt:399) ROOT CAUSE. SyncChipState.resolve is documented "Pure + unit-tested" but reached for two ambient dependencies: the process-wide Application resources (shortSyncAgo -> uiString -> NoopApplication.localizedString, which is checkNotNull(instance) and throws because onCreate never runs) and the system clock. These are plain JVM tests with no Robolectric, so no Application is ever attached. What made it look arbitrary: only the < 60s branch of shortSyncAgo needs a translated word, every other bucket being digits plus a unit letter. Tracing all six cases confirms exactly one reaches that branch without short-circuiting — backfilling_takesPriorityOverLastSyncedAt also uses now - 5 but returns Syncing first, and isSyncedWithAgeText at 65s lands in the Nm branch. FIX. Hoist both dependencies to parameters. SyncStatusChip — a composable that already depends on the clock and the string catalog — resolves them and passes them down, so the decision becomes genuinely pure. Same injected-clock shape as recordingStateFor directly above it. Dropping the now unused com.noop.R import leaves TodayScoring.kt framework-free: analytics, data and java.time only. The tests become deterministic (a fixed nowSec rather than System.currentTimeMillis()) and gain a case for the sub-minute branch, previously unreachable from a unit test and now the regression guard against the lookup migrating back inside. PARITY. The Swift twin is deliberately unchanged, called out per CLAUDE.md. Swift computes mins = secs/60, hrs = mins/60, days = hrs/24 where Kotlin uses 3600/86400 directly; floor division composes, so the two agree across every bucket boundary — verified over 255 offsets with zero mismatches, along with the max(0,) / coerceAtLeast(0) clamp and the priority order. Only the SIGNATURES differ, and a KDoc note says why so it does not get "restored" later. No behaviour change in what renders. One honest exception: nowLabel is now resolved on every recomposition rather than only in the < 60s branch. It is a getString call, and there is no @Preview anywhere in com.noop.ui that an unconditional resolve could break. VERIFICATION. Gradle cannot run on the reviewing host (aarch64; aapt2 ships x86-64 only), so the implementation was extracted verbatim, compiled with the Kotlin compiler from the Gradle cache and executed: all seven cases green. All eight resolve call sites carry all six named args and the sole shortSyncAgo call is updated — worth checking directly, since android.yml is disabled and nothing compiles Kotlin on a PR. Current main has exactly one production call site, so no semantic conflict. i18n-coverage and source-hygiene pass; l10n_today_screen_sync_chip_now_c9bc849a is still referenced, from TodayScreen.kt now, and still translated in all five locales. Regression dates to #806, merged one day earlier. testFullDebugUnitTest is pure JVM with no emulator and would have caught it on the PR; enabling that job alone, while the heavier compile job stays off, is worth a separate change.
Problem
The v8 Liquid Today header shipped with no backfill/sync indication at all —
only the classic Today header's
SyncStatusChiphad one, and Android alreadyputs its own equivalent next to the battery ring. On a WHOOP 5.0, whose history
offloads are rare, a Liquid user had no way to see a sync ever happening except
drilling into the collapsed Data Sources card (
LiquidSyncStatusRow).Closes the visible part of #245 — the "no sync indicator" report is really two
things: this always-on chip (what's missing), and a byte/percentage progress
readout (not addressed, see below).
What changed
SyncStatusChipinto a sharedSyncChipState.resolve(live:), so the classic and Liquid headers render fromthe exact same three-state decision (syncing / synced / 5.0 experimental /
hidden) and can't drift on when to show what.
LiquidSyncChipnext toLiquidBatteryButtonin the Liquid header,restyled to that header's own dark-hero icon idiom (
.white.opacity(0.16)fill, matching
LiquidAddButton) instead of reusingSyncStatusChip'slight-surface chrome, which would read poorly over the photo/gradient hero.
SyncStatusChipnext to thebattery ring.
Explicitly not done
A commenter asked for byte/percentage sync progress (citing an old "Goose"
app). Not attempted:
syncChunksThisSession's own doc comment is explicitthat the WHOOP protocol never reveals a total pending-record count, so a
chunk counter is the honest ceiling — a fabricated percentage would be
guessing.
Verification
xcodebuild … -scheme Strand -destination 'platform=macOS' build— clean.xcodebuild … -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' build— clean.SyncChipStateTests(6 cases: each state, plus priority order betweenstates) — run via
xcodebuild … -scheme Strand test, all passing.is a pure UI/state-plumbing change (no BLE wiring touched), but a visual
check on-device before merging out of draft would still be good — I'll do
that on my own WHOOP 5.0 next sync.