diff --git a/Strand/Data/MetricCatalog.swift b/Strand/Data/MetricCatalog.swift index ac0463160..2f3cd633f 100644 --- a/Strand/Data/MetricCatalog.swift +++ b/Strand/Data/MetricCatalog.swift @@ -1,4 +1,5 @@ import Foundation +import StrandAnalytics /// One interrogable metric: how to fetch it (key+source), how to label/format it, and whether /// higher is better (drives delta tinting). The Metric Explorer + Compare are built from this list. @@ -32,6 +33,15 @@ struct MetricDescriptor: Identifiable, Hashable { /// gate (`key == "strain"`) — the only value-converting metric in the catalog. private var isEffort: Bool { key == "strain" } + /// #111: the skin_temp metric's stored value is EITHER an absolute skin temperature (WHOOP export gives + /// absolute °C) OR a signed DEVIATION from the personal baseline (±°C, the live/computed pipeline) — + /// `VitalBands.isAbsoluteSkinTemp(v)` (v >= 20 °C) tells them apart per value. A DEVIATION must convert + /// to °F by ×9/5 with NO +32 offset; adding +32 to a deviation produced nonsense ONLY in Fahrenheit + /// (a −4.2 °C deviation rendered as "24.4 °F" — the bug), while Celsius appended the raw deviation and + /// looked fine. An ABSOLUTE reading still uses the full C→F (×9/5 + 32). Key-based, mirroring `isEffort` + /// and the Android HealthScreen per-value branch. + private var isSkinTemp: Bool { key == "skin_temp" } + func format(_ v: Double) -> String { let n = decimals == 0 ? String(Int(v.rounded())) : String(format: "%.\(decimals)f", v) return unit.isEmpty ? n : "\(n) \(unit)" @@ -55,7 +65,13 @@ struct MetricDescriptor: Identifiable, Hashable { effortScale: EffortScale = .hundred) -> String { switch unit { case "kg": return UnitFormatter.massFromKilograms(v, system: system) - case "°C": return UnitFormatter.temperatureFromCelsius(v, unit: temperature, decimals: decimals) + case "°C": + // #111: a skin-temp DEVIATION (v < 20 °C) scales without the +32 offset; an absolute reading + // (WHOOP export, v >= 20 °C) keeps the full C→F. Every other °C metric is absolute. + if isSkinTemp && !VitalBands.isAbsoluteSkinTemp(v) { + return UnitFormatter.temperatureDeltaFromCelsius(v, unit: temperature, decimals: decimals) + } + return UnitFormatter.temperatureFromCelsius(v, unit: temperature, decimals: decimals) default: return isEffort ? format(v, effortScale: effortScale) : format(v) } } diff --git a/StrandTests/UnitFormatterTests.swift b/StrandTests/UnitFormatterTests.swift index e80bd180b..032fcd8a8 100644 --- a/StrandTests/UnitFormatterTests.swift +++ b/StrandTests/UnitFormatterTests.swift @@ -101,6 +101,23 @@ final class UnitFormatterTests: XCTestCase { XCTAssertEqual(UnitFormatter.temperatureDeltaFromCelsius(0.6, unit: .celsius), "0.6 °C") } + // #111: skin_temp holds EITHER a signed deviation (v < 20 °C) or an absolute reading (v >= 20 °C). + // A DEVIATION must convert ×9/5 with NO +32 — the absolute formula turned a −4.2 °C deviation into the + // reported nonsense "24.4 °F". An ABSOLUTE reading must still get the full C→F. Pin both branches. + func testSkinTempMetricPicksDeltaVsAbsoluteByValue() { + guard let skin = MetricCatalog.all.first(where: { $0.key == "skin_temp" }) else { + return XCTFail("skin_temp descriptor missing") + } + // DEVIATION (< 20 °C): ×9/5, no +32 — NOT the bogus absolute 24.4 °F. + XCTAssertEqual(skin.format(-4.2, system: .imperial, temperature: .fahrenheit), "-7.6 °F") + XCTAssertEqual(skin.format(0.6, system: .imperial, temperature: .fahrenheit), "1.1 °F") + // ABSOLUTE (>= 20 °C, e.g. an imported WHOOP export reading): full C→F with +32 — 34 °C = 93.2 °F. + XCTAssertEqual(skin.format(34.0, system: .imperial, temperature: .fahrenheit), "93.2 °F") + // Celsius is unchanged for both — this always looked right; only °F was broken. + XCTAssertEqual(skin.format(0.6, system: .metric, temperature: .celsius), "0.6 °C") + XCTAssertEqual(skin.format(34.0, system: .metric, temperature: .celsius), "34.0 °C") + } + // MARK: - Preference resolution func testTemperatureOverrideResolution() {