Skip to content

Fix unit/scale preferences ignored across six display sites#3

Open
digitalerdude wants to merge 3 commits into
mainfrom
fix-unit-preference-bypasses
Open

Fix unit/scale preferences ignored across six display sites#3
digitalerdude wants to merge 3 commits into
mainfrom
fix-unit-preference-bypasses

Conversation

@digitalerdude

Copy link
Copy Markdown
Owner

What this PR does

Fixes six display sites that formatted a convertible metric without routing through UnitFormatter — the same bug class as ryanbr#101 (skin temp ignoring the Fahrenheit preference in Deep Timeline) and ryanbr#45 (Effort scale ignored on the Today hero gauge), found by sweeping the codebase for the pattern. Display-only throughout: stored values stay SI, every stat/trend/threshold is still computed on the stored values.

Site Bug Fix
TodayView — "Your cards" skin-temp card Raw Δ°C formatted with "%+.1f°", °F preference ignored UnitFormatter.temperatureDeltaFromCelsius (×9/5, no +32 offset for a delta), signed, with the resolved unit label ("+0.3 °C" / "+0.5 °F")
Trends report PDFRangeReportEngine + TrendsReportView Skin-temp row hardcoded "°C"; Strain shown as the raw 0–100 integer regardless of the #268 Effort-scale choice — in an artifact users may hand to a clinician RangeReportEngine.build gains effortDisplayFactor / skinTempDisplayFactor / skinTempUnitLabel (defaults keep existing callers byte-identical), mirroring WeeklyDigestEngine's effortDisplayFactor precedent, so headline prose converts too; the page's Avg/Min/Max read-outs convert in valueText
LiquidTodayView — Key Metrics Strain tile Raw 0–100 with a literal "%" (wrong unit + ignored the 0–21 axis), while the hero gauge and workout card in the same file already converted Routes through effortText with a "/ 100" / "/ 21" suffix
LiquidTodayView — workout sub-line Hardcoded "%.1f km", miles preference ignored UnitFormatter.distanceFromMeters
FusedRecordView — "Your Data, Fused" FusionFormat hardcoded "%.1f°C" for the absolute skin temperature Formatter takes the resolved TemperatureUnit as a required parameter (no default, so future call sites can't silently bypass); converts via temperatureFromCelsius (full ×9/5 + 32 — absolute, not a delta)
AppModel — illness watch Fired-signal label "skin temp +0.7 °C" (Heads-Up card, amber banner, notification) formatted the raw Δ°C New UnitPrefs.currentTemperatureUnit() helper for non-view call sites (mirrors currentEffortDisplayFactor)

Checked and deliberately left unchanged: CoupledView (pinned to the WHOOP 0–21 axis by documented design), SkinTempCardsView (the "+0.7 °C" strings are DEBUG preview fixtures), AICoach context blocks (SI on purpose — model context, not UI), ActivityFileImporter notes (persisted at import time, Kotlin parity), Settings/AppleHealth/Workouts/WorkoutDetail (already branch correctly).

Android follow-up: the Kotlin RangeReport mirror should gain the same three display parameters to stay in lockstep — its UnitFormatter already has the effortDisplayFactor precedent from the WeeklyDigest work. The app-side sites above are worth checking on Android too.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • swift test in Packages/StrandAnalytics: 21/21 RangeReportTests pass — the new engine parameters default to the current behaviour, so the existing oracle stays byte-identical (the Kotlin twin's RangeReportTest is unaffected until it mirrors the parameters).
  • Full macOS Debug build (xcodebuild -scheme Strand) is clean; the only warnings in the log are pre-existing and unrelated (actor-isolation / localization deprecations).
  • No BLE-path changes — everything here is display formatting.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (not touched — Kotlin mirror flagged as follow-up above)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Follow-up to ryanbr#101 and ryanbr#45 (same bug class, remaining instances). Touches the display side of #268 (Effort scale) and #457 (trends report).

🤖 Generated with Claude Code

digitalerdude and others added 2 commits July 8, 2026 23:04
Same bug class as ryanbr#101 (skin temp ignoring the Fahrenheit preference) and
ryanbr#45 (Effort scale ignored on the hero gauge), found by sweeping every
display site that formats a convertible metric without routing through
UnitFormatter. Display-only throughout: stored values stay SI, every
stat/trend/threshold is computed on the stored values.

Fixed sites:

- TodayView (dashboard "Your cards" skin-temp card): formatted the raw
  deviation with "%+.1f°" — now converts via
  UnitFormatter.temperatureDeltaFromCelsius (×9/5, no +32 offset for a
  delta) and shows the resolved unit label ("+0.3 °C" / "+0.5 °F").

- Trends report PDF (RangeReportEngine + TrendsReportView): the exported
  report hardcoded "°C" for the skin-temp row and showed Strain as the
  raw 0–100 integer regardless of the #268 Effort-scale choice — in an
  artifact users may hand to a clinician. RangeReportEngine.build gains
  effortDisplayFactor / skinTempDisplayFactor / skinTempUnitLabel
  parameters (defaults keep existing callers and all 21 package tests
  byte-identical), mirroring WeeklyDigestEngine's effortDisplayFactor
  precedent, so the headline prose converts too. The page's Avg/Min/Max
  read-outs convert in valueText; the native 0–100 scale keeps its
  whole-number style, the 0–21 axis reads to one decimal.

- LiquidTodayView (Key Metrics strain tile): showed the raw 0–100 value
  with a literal "%" — wrong unit, and it ignored the 0–21 axis — while
  the hero gauge and workout card in the same file already converted.
  Now routes through effortText with a "/ 100" / "/ 21" suffix.

- LiquidTodayView (workout sub-line): hardcoded "%.1f km" — now
  UnitFormatter.distanceFromMeters, honouring the miles preference.

- FusedRecordView ("Your Data, Fused"): FusionFormat hardcoded
  "%.1f°C" for the absolute skin temperature. The formatter now takes
  the resolved TemperatureUnit as a required parameter (no default, so
  future call sites can't silently bypass) and converts via
  temperatureFromCelsius (full ×9/5 + 32 — absolute, not a delta).

- AppModel (illness watch): the fired-signal label "skin temp +0.7 °C"
  in the Heads-Up card, the amber banner and its notification formatted
  the raw Δ°C. New UnitPrefs.currentTemperatureUnit() helper resolves
  the preference for non-view call sites (mirrors
  currentEffortDisplayFactor), and the label converts through it.

Checked and deliberately left unchanged:
- CoupledView: pinned to the WHOOP 0–21 axis by documented design.
- SkinTempCardsView: the "+0.7 °C" strings are DEBUG preview fixtures.
- AICoach context blocks: SI on purpose (model context, not UI).
- ActivityFileImporter notes: persisted at import time, Android parity.
- Settings/AppleHealth/Workouts/WorkoutDetail: already branch correctly.

Android: the Kotlin RangeReport mirror should gain the same three
display parameters to stay in lockstep (its UnitFormatter already has
the effortDisplayFactor precedent from the WeeklyDigest work).

Verified: full macOS Debug build clean; StrandAnalytics RangeReportTests
21/21 green (engine defaults unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecker

test (WhoopProtocol) failed on the PR run with "the compiler is unable to
type-check this expression in reasonable time" at DecoderOracleTests.swift:86
— the one-line optional-chained magnitude ((gx ?? 0) * (gx ?? 0) + ...) blew
the type-checker's time budget on the CI runners (it squeaked through on a
local arm64 machine, so this never surfaced locally). Pre-existing on the
branch point, unrelated to this PR's display fixes; fixed here so the run
can go green.

Bind the components to concrete Doubles first and sum the squares in a typed
sub-expression — same assertions, same tolerance, no behaviour change.
All 241 WhoopProtocol tests pass locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@digitalerdude

Copy link
Copy Markdown
Owner Author

CI note: test (WhoopProtocol) failed with a Swift type-checker timeout ("unable to type-check this expression in reasonable time") at DecoderOracleTests.swift:86 — a pre-existing one-line optional-chained gravity-magnitude expression that exceeds the type-checker budget on the CI runners but squeaks through on local arm64 machines. Unrelated to the display fixes in this PR; ffb8ed9 splits it into typed sub-expressions (same assertions, no behaviour change) so the run can go green. All 241 WhoopProtocol tests pass.

build (NOOPiOS, iOS Simulator) failed on the PR gate with "value of type
'Self' has no member 'glassEffect'" at StrandiOS/App/RootTabView.swift:623.
The Liquid Glass tab bar (045a302) compiles only against the iOS 26 SDK —
the runtime #available guard doesn't help at compile time. The release and
testing workflows were already moved to macos-26 for exactly this reason
(fork-release.yml: "iOS 26 Liquid Glass (glassEffect) needs the iOS 26 SDK
→ Xcode 26 → macos-26"), but the app-build.yml PR gate still ran the whole
matrix on macos-15.

Give each matrix entry its own runner: Strand stays on macos-15 (matching
the release macos job), NOOPiOS moves to macos-26. Pre-existing on the
branch point, unrelated to this PR's display fixes; fixed here so the run
can go green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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