Skip to content

Add a sync-status chip to the Liquid Today header (#245) - #806

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
digitalerdude:fix/245-liquid-sync-chip
Jul 26, 2026
Merged

Add a sync-status chip to the Liquid Today header (#245)#806
ryanbr merged 2 commits into
ryanbr:mainfrom
digitalerdude:fix/245-liquid-sync-chip

Conversation

@digitalerdude

Copy link
Copy Markdown

Problem

The v8 Liquid Today header shipped with no backfill/sync indication at all —
only the classic Today header's SyncStatusChip had one, and Android already
puts 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

  • Extracted the state-picking logic already in SyncStatusChip into a shared
    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.
  • Added LiquidSyncChip next to LiquidBatteryButton in the Liquid header,
    restyled to that header's own dark-hero icon idiom (.white.opacity(0.16)
    fill, matching LiquidAddButton) instead of reusing SyncStatusChip's
    light-surface chrome, which would read poorly over the photo/gradient hero.
  • No Android change — it already places its SyncStatusChip next to the
    battery 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 explicit
that 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.
  • New SyncChipStateTests (6 cases: each state, plus priority order between
    states) — run via xcodebuild … -scheme Strand test, all passing.
  • Not yet done: seeing the chip's three states on a real strap session. This
    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.

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.
@ryanbr

ryanbr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Reviewed (draft, so flagging what to settle before it leaves that state).

The extraction is behaviour-preserving. SyncChipState.resolve keeps the original branch order exactly — backfillinglastSyncedAthistorySyncExperimental → nothing — and shortAgo moved verbatim. The classic chip keeps its own StrandPalette tints while Liquid gets the dark-hero idiom, so only the chrome differs. That's the right shape: shared decision, separate styling.

Android claim checks outSyncStatusChip is already at TodayScreen.kt:2093, and Android has no Liquid/classic split, so there's genuinely nothing to mirror.

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.

pill(system:text:a11y:) and the classic chip(...) both take String and render Text(text). That selects Text(_ content: S) where S: StringProtocol, not the LocalizedStringKey overload — string literals localize, String variables don't. And none of the strings are in the catalog:

ABSENT  'live'
ABSENT  'now'
ABSENT  'Syncing strap history, %lld chunks'
ABSENT  'Strap history synced %@ ago'
ABSENT  'Connected; strap history sync is experimental on this strap'

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 Text(stringVariable) is invisible to it.

Worth fixing while the code is open, since it's small — take LocalizedStringKey for the display text, keep the numeric interpolation, and add the five entries. Otherwise the Liquid header ships the same untranslated strings the classic one already has, in a more prominent place.

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.

@ryanbr

ryanbr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Checked the Android twin properly rather than just confirming it exists.

Behaviour parity holds exactly. SyncStatusChip at TodayScreen.kt:2128 uses the same branch order (backfillinglastSyncAt != nullhistorySyncExperimental → nothing), the same strings, and shortSyncAgo matches your shortAgo boundary for boundary — <60 → "now", <3600 → "Nm", <86400 → "Nh", else "Nd". Nothing to fix there.

Test parity is what this PR changes. iOS gains SyncChipState as a testable enum with your 6 cases pinning the state machine. Android keeps the same logic inline inside the @Composable, where it can't be unit-tested. So after this lands, reordering the priority — say putting historySyncExperimental above lastSyncedAt — fails a test on iOS and silently diverges on Android.

That's worth closing while the shape is fresh, and the repo already has the pattern: TodayScoring.kt exists precisely to hold pure logic lifted out of TodayScreen.kt. A SyncChipState sealed class there with the same six cases would make the twin real rather than nominal.

And the localization gap is both platforms, not just iOS. Android's chip renders raw literals too — "live", "Syncing strap history, $chunks chunks", "Strap history synced ... ago" — with no uiString(R.string.…) anywhere in SyncStatusChip or ChipCapsule. So the five untranslated strings I flagged above are untranslated on Android as well, and have been since #245's original chip. That makes it a slightly bigger job than "fix the new Liquid chip", but also a more worthwhile one: one set of catalog entries fixes three chips across two platforms.

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.

@jbergler

Copy link
Copy Markdown

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?

@ryanbr

ryanbr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

@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: pagesBehind = W − U, write pointer minus read pointer, against a ring capacity of 131072 in every capture we have. A chunk is a HISTORY_END boundary in the offload stream — the unit the Backfiller accumulates, decodes and acks. Nothing relates the two, and the strap never tells us records-per-page, so you can't convert one into the other.

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. (P₀ − Pₙ) / P₀ where P₀ is captured at sync start.

The obstacle: we only ask once. GET_DATA_RANGE is sent from a single site — BLEManager.swift:4080, in the connect handshake, commented "refresh the strap's stored range for the watchdog" — and never again during the offload. So today there's exactly one sample, which gives you a denominator and no numerator.

Making it work needs re-polling GET_DATA_RANGE periodically mid-offload, and that's the part I'd want evidence for before building: it's an extra command on a link that's already saturated with historical frames, and #377's "offload throughput is firmware-paced (~10 records/s)" suggests the strap is the bottleneck rather than us. Whether a poll every N chunks perturbs that is a hardware question, not a code-reading one.

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.

@ryanbr
ryanbr marked this pull request as ready for review July 26, 2026 06:38
…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.
@digitalerdude

digitalerdude commented Jul 26, 2026

Copy link
Copy Markdown
Author

Addressed both findings in 8d49c38:

  • Localization (comment): the chip's display text and VoiceOver labels now go through String(localized:) instead of a bare String, on both the classic and Liquid chip. Added the five catalog entries to Strand/Resources/Localizable.xcstrings (de/es/fr/it/pt-PT/ru/zh-Hans/zh-Hant) — that catalog is shared by both the Strand and NOOPiOS targets, so one edit covers both. Tools/i18n_audit.py --ci origin/main is clean on the Apple side.

  • Test parity (comment): extracted a Kotlin SyncChipState sealed class + shortSyncAgo into TodayScoring.kt, mirroring the Swift enum's four cases and priority order exactly, and added SyncChipStateTest with the same six cases as the iOS SyncChipStateTests. SyncStatusChip/ChipCapsule now go through uiString(R.string...) for the same five strings, across Android's five shipped locales (de/es/fr/pt-rPT/zh) — since Android's chip predates this PR (Sync indicator missing (or I am dumb?) #245), this closes both the parity gap and the pre-existing localization gap in one pass, as suggested.

One thing surfaced while verifying: Android's testFullDebugUnitTest currently fails to compile for the whole module — three FakeRegistryDao test fakes (DeviceRegistryTest.kt, SourceCoordinatorAdoptionTest.kt, RegistryDayOwnerSourceTest.kt) are missing ~25 methods DeviceRegistryDao now declares (the reKey* family + pairedDevice/deletePairedDeviceRow). Confirmed unrelated to this PR — none of those three files are touched here, and the gap predates this branch. compileFullDebugKotlin (main sources) and the i18n audit both pass; I couldn't get a real testFullDebugUnitTest run locally to confirm SyncChipStateTest executes, only that it compiles. Flagging that gap separately rather than fixing it here.

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.

@ryanbr
ryanbr merged commit 1a7b002 into ryanbr:main Jul 26, 2026
1 check passed
@ryanbr

ryanbr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

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 — TodayScreen.kt:2142 already ships "live" as a bare literal on main and shortSyncAgo already produces "now"/"Nm"/"Nh"/"Nd". You moved already-unlocalized strings into a testable type; holding that to a standard the surrounding code doesn't meet was wrong of me. Worth fixing across both platforms as its own change.

Extracting Android's SyncChipState. Pure test parity — Android's logic is correct and identical, it just isn't unit-testable where it sits. Not your job on a PR that improved the iOS side.

Worth knowing for anyone reading later: the i18n gate can't see this class of issue at all. The chip renders Text(variable) and the audit only reads literals, so green means less than it looks here.

ryanbr pushed a commit that referenced this pull request Jul 27, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants