Skip to content

fix(widget): show HRV, resting HR and scores for a wearer with no Charge yet - #36

Merged
vuongvm812 merged 1 commit into
mainfrom
34-widget-garmin-metrics
Nov 2, 2026
Merged

vuongvm812 merged 1 commit into
mainfrom
34-widget-garmin-metrics

Conversation

@vuongvm812

Copy link
Copy Markdown
Owner

Refs #34
Refs #31

Problem

The widget anchored only on a day with a Charge. For a Garmin-only wearer whose Charge is still calibrating, no day qualifies, so every cell showed "–". HRV and resting HR were read off that same Charge-gated anchor.

Changes

  • Anchor fallback (Swift + Kotlin). Repository.widgetAnchor / widgetAnchorRow keep the perf(ui): pose the animating surfaces still in battery saver (#909) ryanbr/noop#911 rule. Only when no day up to today has a Charge do they fall back to the freshest day up to and including today that has Effort, a night, HRV or resting HR (hasWidgetValue on both platforms). A future-dated row is still never picked.
  • Vitals (iOS widget). New Repository.widgetVitals resolves HRV and resting HR exactly like the Today tiles: today's row first, then lastHrvDay / lastRestingHrDay (the same functions TodayView and LiquidTodayView call). WidgetPublish uses it instead of reading the anchor. The Android widget has no HRV or resting-HR cell, so there is nothing to wire there.
  • App Group diagnostic. WidgetSnapshot.appGroupDiagnostic() returns one strap-log line, logged at iOS launch, when the resolved suite has no defaults store, or has one but no shared container (containerURL(forSecurityApplicationGroupIdentifier:) is nil, which is the "silently unshared" case). The line names AppGroupIdentifier, ALTAppGroups and the resolved suite. A healthy build logs nothing.

Battery (WHOOP/Oura only) and live HR (only while Live or a workout is open) are left as is, per the issue. Charge, Effort and Rest for imported history depend on #31.

Parity oracle

The Swift resolveToday + widgetAnchor + hasWidgetValue were lifted verbatim out of Repository.swift and compiled with swiftc -O. They ran over 2,058 cases: rows 17/18/19 each absent, empty, or carrying one of Charge / Effort / sleep / HRV / resting HR, plus a future row absent / scored / with Effort, under the daytime keys and the pre-04:00 carve-out keys. That stdout is pasted verbatim into WidgetAnchorTest.anchorMatchesSwiftOracle, which runs the same enumeration through the Kotlin code. It never selected the future row.

Local verification (Actions is disabled on this fork)

  • Strand (macOS): builds. xcodebuild … -scheme Strand test -only-testing:StrandTests/WidgetAnchorTests -only-testing:StrandTests/WidgetSnapshotTests ran 30 tests with 0 failures. That covers new tests for no Charge, Charge on an older day, today with Charge (existing), the future-row guard, vitals carry and today-wins, plus the diagnostic line.
  • NOOPiOS: the iOS app and widget extension build for the iOS Simulator (BUILD SUCCEEDED). This machine lacks the watchOS 26.2 platform the scheme requires for its embedded Watch app. So the build used a scratch spec identical to project.yml minus the NOOPWatch embed dependency. The Watch targets are untouched by this PR.
  • Android: ./gradlew testFullDebugUnitTest --no-build-cache --rerun-tasks passes WidgetAnchorTest (14/14, including the oracle). Of 6,881 tests, 2 failures remain, both in RecoveryDriversTest (expected -0.5 but was -0.4999999999999929). They fail identically on a clean origin/main checkout, so they predate this change.
  • Parity ratchet: python3 Tools/parity_ratchet.py --base origin/main --offline reports errors=0.
  • Doc lint: python3 Tools/doc_comment_lint.py reports no new detached doc comments.
  • i18n: python3 Tools/i18n_audit.py --ci origin/main is OK. The new log line is diagnostic and not localized, like the other strap-log lines.

Not verified

  • I have no sideloaded (AltStore / SideStore) build, so there is no record yet of what resolveSuiteName resolves there. The new log line captures it on the next sideloaded launch where the group is not shared.
  • Nothing has been checked on a device against a real Garmin-only profile.

…rge yet

The widget anchor only accepted a day with a Charge, so a Garmin-only
wearer still calibrating got "-" in every cell.

- Anchor: when no day up to today has a Charge, fall back to the freshest
  day up to and including today that carries Effort, a night, HRV or
  resting HR. Same rule in Swift (Repository.widgetAnchor) and Kotlin
  (widgetAnchorRow), with a Swift-generated oracle pinned in the Kotlin test.
- Vitals (iOS widget): HRV and resting HR now resolve the way the Today
  tiles do, today's row first then lastHrvDay / lastRestingHrDay, via
  Repository.widgetVitals instead of the Charge-gated anchor.
- Diagnostics: one always-on strap-log line when the resolved App Group
  has no defaults store or no shared container, naming AppGroupIdentifier,
  ALTAppGroups and the resolved suite.

Refs #34
Refs #31
@vuongvm812
vuongvm812 merged commit 2f5491e into main Nov 2, 2026
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.

1 participant