Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion Strand/Data/MetricCatalog.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)"
Expand All @@ -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)
}
}
Expand Down
17 changes: 17 additions & 0 deletions StrandTests/UnitFormatterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading