Skip to content
Open
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
6 changes: 5 additions & 1 deletion Strand/BLE/PuffinExperiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ enum PuffinExperiment {
/// ON. Mirrors the Android `NoopPrefs.KEY_SPO2_CANDIDATE_DISPLAY`.
static let spo2CandidateDisplayKey = "noopSpo2CandidateDisplay"

static var spo2CandidateDisplayEnabled: Bool { UserDefaults.standard.bool(forKey: spo2CandidateDisplayKey) }
/// Default ON in this fork: the strap computes this value itself (wearable5.0 FINDINGS §13), and the
/// nightly figure is now the windowed median rather than a raw mean. An explicit off still wins.
static var spo2CandidateDisplayEnabled: Bool {
UserDefaults.standard.object(forKey: spo2CandidateDisplayKey) as? Bool ?? true
}

/// Opt-in "Personal daytime-stress baseline" (#463): score TODAY's intraday stress timeline against a
/// PERSONAL cross-day rolling baseline (Oura-style `.baselineRelative`) instead of the day's own calm
Expand Down
4 changes: 2 additions & 2 deletions Strand/Liquid/LiquidTodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1053,7 +1053,7 @@ struct LiquidTodayView: View {
// is the DEFAULT Today screen on iOS 26. The subtitle is the slot this card has.
cardLink(.metric("spo2"),
title: card.title,
sub: spo2Candidate != nil ? String(localized: "strap estimate (unverified)") : card.subtitle,
sub: spo2Candidate != nil ? String(localized: "strap reading") : card.subtitle,
// Em dash, not the en dash the stub used: the classic Blood Oxygen card and
// skinTempCardValue both return "—", so the stub's "–" would have left the two
// adjacent cards printing different glyphs for the same "no reading" state.
Expand Down Expand Up @@ -1420,7 +1420,7 @@ struct LiquidTodayView: View {
: nil
let spo2 = spo2Real ?? spo2CandidateValue
ktile(String(localized: "Blood Oxygen"), icon: keyMetricIcon(metric), intText(spo2), "%", StrandPalette.metricCyan, fracOver(spo2, 100), key: spo2CandidateValue != nil ? "spo2_candidate" : "spo2",
caption: spo2CandidateValue != nil ? String(localized: "strap estimate (unverified)") : nil)
caption: spo2CandidateValue != nil ? String(localized: "strap reading") : nil)
case .respiratory:
let resp = displayDay?.respRateBpm ?? vitalsDay?.respRateBpm ?? respDay?.respRateBpm
ktile(String(localized: "Respiratory"), icon: keyMetricIcon(metric), resp.map { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) } ?? "—", "rpm", StrandPalette.accent, fracOver(resp, 24), key: "resp_rate")
Expand Down
4 changes: 2 additions & 2 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@
"no data yet": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "noch keine Daten"}}, "es": {"stringUnit": {"state": "translated", "value": "aún sin datos"}}, "fr": {"stringUnit": {"state": "translated", "value": "pas encore de données"}}, "it": {"stringUnit": {"state": "translated", "value": "ancora nessun dato"}}, "pl": {"stringUnit": {"state": "translated", "value": "brak danych"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "ainda sem dados"}}, "ru": {"stringUnit": {"state": "translated", "value": "данных пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "暂无数据"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "暫無資料"}}
} },
"strap estimate (unverified)": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Strap-Schätzung (nicht verifiziert)"}}, "es": {"stringUnit": {"state": "translated", "value": "estimación de la banda (sin verificar)"}}, "fr": {"stringUnit": {"state": "translated", "value": "estimation du bracelet (non vérifiée)"}}, "it": {"stringUnit": {"state": "translated", "value": "stima della fascia (non verificata)"}}, "pl": {"stringUnit": {"state": "translated", "value": "szacunek opaski (niezweryfikowany)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "estimativa da pulseira (não verificada)"}}, "ru": {"stringUnit": {"state": "translated", "value": "оценка ремешка (не проверена)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环估算值(未验证)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環估算值(未驗證)"}}
"strap reading": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Messwert des Armbands"}}, "es": {"stringUnit": {"state": "translated", "value": "lectura de la banda"}}, "fr": {"stringUnit": {"state": "translated", "value": "mesure du bracelet"}}, "it": {"stringUnit": {"state": "translated", "value": "lettura della fascia"}}, "pl": {"stringUnit": {"state": "translated", "value": "odczyt opaski"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "leitura da pulseira"}}, "ru": {"stringUnit": {"state": "translated", "value": "показание ремешка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环读数"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環讀數"}}
} },
"toggle ON · no estimate yet": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Schalter AN · noch keine Schätzung"}}, "es": {"stringUnit": {"state": "translated", "value": "interruptor activado · aún sin estimación"}}, "fr": {"stringUnit": {"state": "translated", "value": "commutateur activé · pas encore d'estimation"}}, "it": {"stringUnit": {"state": "translated", "value": "interruttore attivo · nessuna stima"}}, "pl": {"stringUnit": {"state": "translated", "value": "przełącznik włączony · brak szacunku"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "interruptor ligado · ainda sem estimativa"}}, "ru": {"stringUnit": {"state": "translated", "value": "переключатель включён · оценки пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开关已开启 · 尚无估算值"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開關已開啟 · 尚無估算值"}}
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ struct SettingsView: View {
/// #103 opt-in: surfaces the WHOOP 5/MG `spo2_candidate_82` nightly mean in the Blood Oxygen tile
/// as a "strap estimate (unverified)" fallback when no calibrated `spo2Pct` exists. Display-only —
/// writes nothing to the strap. See [PuffinExperiment.spo2CandidateDisplayKey].
@AppStorage(PuffinExperiment.spo2CandidateDisplayKey) private var spo2CandidateDisplayEnabled = false
@AppStorage(PuffinExperiment.spo2CandidateDisplayKey) private var spo2CandidateDisplayEnabled = true
@AppStorage(AppModel.ouraAllDayLiveHRKey) private var ouraAllDayLiveHREnabled = false // item 27

/// #1545 opt-in: score Effort with Banister's exponential TRIMP instead of Edwards' heart-rate zones.
Expand Down
4 changes: 2 additions & 2 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,7 @@ struct TodayView: View {
// other candidate-fallback surface uses — never a device name, which would misrepresent an
// unvalidated estimate as a calibrated reading in this table's Source column.
if rawSource == spo2CandidateAttributionSource {
return String(localized: "strap estimate (unverified)")
return String(localized: "strap reading")
}
if rawSource.hasSuffix("-noop") { return String(localized: "On-device") }
if rawSource == deviceId || rawSource == Repository.whoopSource { return Self.whoopBrandName }
Expand Down Expand Up @@ -4043,7 +4043,7 @@ struct TodayView: View {
? String(format: "%.0f%%", locale: AppLanguage.activeLocale, candidateTail!)
: spo2.value
let spo2Caption: String = spo2.value == "—" && candidateTail != nil
? String(localized: "strap estimate (unverified)")
? String(localized: "strap reading")
: (spo2.value == "—" && spo2CandidateOn
? String(localized: "toggle ON · no estimate yet")
: (spo2.caption ?? ""))
Expand Down
4 changes: 2 additions & 2 deletions Strand/Screens/VitalSignsSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ enum BodyVitalSigns {

let respRow = latest(respPoints)
// #103/queue-11a: fall back to the spo2_candidate mean when no calibrated spo2Pct exists. The
// candidate is labelled "strap estimate (unverified)" in the tile caption so it is never read as
// candidate is labelled "strap reading" in the tile caption so it is never read as
// a calibrated blood-oxygen percentage.
let spo2Row = latest(spo2Points) ?? latest(spo2CandidatePoints)
let spo2IsCandidate = spo2Row != nil && latest(spo2Points) == nil
Expand Down Expand Up @@ -345,7 +345,7 @@ enum BodyVitalSigns {
// where Android resolves the selected day — so the ROW differs across platforms by design
// and the parity contract is the relationship between the two tiles, not the row.
missingCaption: spo2IsCandidate
? String(localized: "strap estimate (unverified)")
? String(localized: "strap reading")
: (PuffinExperiment.spo2CandidateDisplayEnabled && spo2Row == nil
? String(localized: "toggle ON · no estimate yet")
: (spo2rawRow != nil
Expand Down
4 changes: 2 additions & 2 deletions StrandTests/VitalReadingsTableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ final class VitalReadingsTableTests: XCTestCase {
}

/// #103/queue-11a follow-up: a spo2 candidate-fallback row (see `spo2CandidateAttributionSource`)
/// must resolve to "strap estimate (unverified)" — the SAME caption every other candidate-fallback
/// must resolve to "strap reading" — the SAME caption every other candidate-fallback
/// surface uses — never a device name, which would misrepresent an unvalidated estimate as a
/// calibrated reading.
func testSpo2CandidateSourceReadsStrapEstimateUnverified() {
let rows = vitalReadingRows(
readings: [VitalReading(day: "2026-08-24", value: 97, source: spo2CandidateAttributionSource)],
unit: "%", strapDeviceId: strap, now: now, format: spo2Format
)
XCTAssertEqual(rows.first?.source, "strap estimate (unverified)")
XCTAssertEqual(rows.first?.source, "strap reading")
}

func testValueReusesModelFormatAndUnit() {
Expand Down
8 changes: 4 additions & 4 deletions StrandTests/VitalSourceResolutionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ final class VitalSourceResolutionTests: XCTestCase {
// MARK: - #103 SpO₂ candidate @82 fallback

/// When the toggle is ON and no calibrated `spo2Pct` exists, the Blood O₂ tile falls back to the
/// `spo2_candidate` mean from metricSeries, labelled "strap estimate (unverified)".
/// `spo2_candidate` value from metricSeries, labelled "strap reading".
func testSpo2CandidateFallsBackWhenNoCalibratedSpo2Pct() {
UserDefaults.standard.set(true, forKey: PuffinExperiment.spo2CandidateDisplayKey)
defer { UserDefaults.standard.set(false, forKey: PuffinExperiment.spo2CandidateDisplayKey) }
Expand All @@ -175,7 +175,7 @@ final class VitalSourceResolutionTests: XCTestCase {
let spo2 = readings.first { $0.key == "spo2" }
XCTAssertEqual(spo2?.value, 96.0)
XCTAssertEqual(spo2?.source, .noopComputed)
XCTAssertTrue(spo2?.missingCaption.contains("strap estimate") == true)
XCTAssertTrue(spo2?.missingCaption.contains("strap reading") == true)
}

/// When the toggle is OFF, the candidate is never surfaced even if data exists.
Expand Down Expand Up @@ -214,8 +214,8 @@ final class VitalSourceResolutionTests: XCTestCase {
let spo2 = readings.first { $0.key == "spo2" }
XCTAssertEqual(spo2?.value, 98)
XCTAssertEqual(spo2?.source, .whoopImport)
// The calibrated caption, NOT the "strap estimate" one.
XCTAssertFalse(spo2?.missingCaption.contains("strap estimate") == true)
// The calibrated caption, NOT the "strap reading" one.
XCTAssertFalse(spo2?.missingCaption.contains("strap reading") == true)
}

// MARK: - Carry staleness
Expand Down
6 changes: 3 additions & 3 deletions android/app/src/main/java/com/noop/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ object NoopPrefs {
/** #103: whether the SpO₂ candidate @82 strap estimate is surfaced in the Blood Oxygen tile.
* Default false — the @82 candidate has split cross-device evidence and ships behind a toggle. */
fun spo2CandidateDisplay(context: Context): Boolean =
of(context).getBoolean(KEY_SPO2_CANDIDATE_DISPLAY, false)
of(context).getBoolean(KEY_SPO2_CANDIDATE_DISPLAY, true)

/**
* [spo2CandidateDisplay] as a flow that re-emits when the user changes it.
Expand Down Expand Up @@ -624,7 +624,7 @@ object NoopPrefs {
// `key` is @Nullable on modern SDKs — it arrives null when the whole file is cleared, which
// reads as "everything changed". The null check is required to compile, not just defensive.
if (key == null || key == KEY_SPO2_CANDIDATE_DISPLAY) {
trySend(changed.getBoolean(KEY_SPO2_CANDIDATE_DISPLAY, false))
trySend(changed.getBoolean(KEY_SPO2_CANDIDATE_DISPLAY, true))
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
Expand All @@ -633,7 +633,7 @@ object NoopPrefs {
// would serve a stale value indefinitely, which is the failure this whole function exists to
// remove. In this order the same interleaving costs at most a duplicate emit, and
// `distinctUntilChanged` drops it.
trySend(prefs.getBoolean(KEY_SPO2_CANDIDATE_DISPLAY, false))
trySend(prefs.getBoolean(KEY_SPO2_CANDIDATE_DISPLAY, true))
awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
}.distinctUntilChanged()

Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2036,7 +2036,7 @@
<string name="settings_chart_classic">Klassisch</string>
<string name="settings_trend_line">Linie</string>
<string name="settings_trend_bars">Balken</string>
<string name="spo2_strap_estimate_caption">Armband-Schätzung (unbestätigt)</string>
<string name="spo2_strap_estimate_caption">Messwert des Armbands</string>
<string name="spo2_toggle_on_no_data">Schalter EIN · noch keine Schätzung</string>
<string name="l10n_devices_screen_forget_device_d6eb6209">Gerät vergessen…</string>
<string name="l10n_devices_screen_forget_device_926e503d">Gerät vergessen</string>
Expand Down Expand Up @@ -2626,7 +2626,7 @@
<string name="l10n_app_changelog_an_effort_scale_you_choose_a_a7c2478d">Eine Effort-Skala nach Wahl, ein Ring, der schlafen darf, und weit weniger vergebliche Neuberechnungen</string>
<string name="l10n_health_screen_this_vital_needs_at_least_two_0e41b8b0">Für dieses Vitalzeichen braucht NOOP mindestens zwei historische Messwerte, um es darzustellen.</string>
<string name="l10n_health_screen_the_blood_oxygen_estimate_is_turned_4c403ab2">Die Sauerstoffsättigungs-Schätzung ist deaktiviert</string>
<string name="l10n_health_screen_your_strap_reports_a_blood_oxygen_349fe34a">Dein Strap liefert eine Schätzung der Sauerstoffsättigung, sie ist aber unbestätigt und standardmäßig aus. Aktiviere die Strap-Schätzung in den Einstellungen, oder importiere einen WHOOP-Export unter Datenquellen für den kalibrierten Wert.</string>
<string name="l10n_health_screen_your_strap_reports_a_blood_oxygen_349fe34a">Dein Strap liefert einen Messwert der Sauerstoffsättigung, aber die Anzeige ist ausgeschaltet. Schalte sie in den Einstellungen ein oder importiere einen WHOOP-Export unter Datenquellen.</string>
<string name="l10n_health_screen_no_blood_oxygen_percentage_from_a_1d3d383e">Kein Sauerstoffsättigungswert von einem WHOOP 4.0</string>
<string name="l10n_health_screen_your_strap_banks_the_raw_optical_b52a0f80">Dein Strap speichert die optischen Rohkanäle, aber NOOP rechnet sie nicht in einen Sauerstoffsättigungswert um - dies bleibt also leer, egal wie lange du ihn trägst. Importiere einen WHOOP-Export unter Datenquellen, um es aus deinem Kontoverlauf zu füllen.</string>
<string name="l10n_settings_screen_ask_android_to_pair_experimental_250a81e9">Android um Kopplung bitten (experimentell)</string>
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2004,7 +2004,7 @@
<string name="settings_chart_classic">Clásico</string>
<string name="settings_trend_line">Línea</string>
<string name="settings_trend_bars">Barras</string>
<string name="spo2_strap_estimate_caption">estimación de la correa (sin verificar)</string>
<string name="spo2_strap_estimate_caption">lectura de la pulsera</string>
<string name="spo2_toggle_on_no_data">activado · aún sin estimación</string>
<string name="l10n_devices_screen_forget_device_d6eb6209">Olvidar dispositivo…</string>
<string name="l10n_devices_screen_forget_device_926e503d">Olvidar dispositivo</string>
Expand Down Expand Up @@ -2613,7 +2613,7 @@
<string name="l10n_app_changelog_an_effort_scale_you_choose_a_a7c2478d">Una escala de Effort a tu elección, un anillo que puede dormir y muchos menos recálculos en vano</string>
<string name="l10n_health_screen_this_vital_needs_at_least_two_0e41b8b0">Esta constante necesita al menos dos lecturas históricas antes de que NOOP pueda representarla.</string>
<string name="l10n_health_screen_the_blood_oxygen_estimate_is_turned_4c403ab2">La estimación de oxígeno en sangre está desactivada</string>
<string name="l10n_health_screen_your_strap_reports_a_blood_oxygen_349fe34a">Tu pulsera ofrece una estimación de oxígeno en sangre, pero no está verificada y viene desactivada. Actívala en Ajustes, o importa una exportación de WHOOP en Fuentes de datos para el valor calibrado.</string>
<string name="l10n_health_screen_your_strap_reports_a_blood_oxygen_349fe34a">Tu pulsera ofrece una lectura de oxígeno en sangre, pero su visualización está desactivada. Actívala en Ajustes para verla o importa una exportación de WHOOP en Fuentes de datos.</string>
<string name="l10n_health_screen_no_blood_oxygen_percentage_from_a_1d3d383e">Sin porcentaje de oxígeno en sangre desde un WHOOP 4.0</string>
<string name="l10n_health_screen_your_strap_banks_the_raw_optical_b52a0f80">Tu pulsera guarda los canales ópticos en bruto, pero NOOP no los convierte en un porcentaje de oxígeno en sangre, así que esto seguirá vacío por mucho que la lleves. Importa una exportación de WHOOP en Fuentes de datos para rellenarlo con tu historial.</string>
<string name="l10n_settings_screen_ask_android_to_pair_experimental_250a81e9">Pedir a Android que vincule (experimental)</string>
Expand Down
Loading