From 01507d5404185cad2a2f25a8f6e51bfcad497745 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 10:34:20 +0200 Subject: [PATCH 01/12] fix(i18n): localize home surfaces --- .../StrandAnalytics/IllnessSignalEngine.swift | 49 +- .../StrandAnalytics/ReadinessEngine.swift | 22 +- .../Sources/StrandDesign/DayNavBar.swift | 18 +- .../Resources/Localizable.xcstrings | 64 ++ Strand/App/AppModel.swift | 37 +- Strand/Liquid/LiquidTodayView.swift | 27 +- Strand/Resources/Localizable.xcstrings | 465 ++++++++++++-- Strand/Screens/HealthAlertBanner.swift | 26 +- Strand/Screens/SkinTempCardsView.swift | 175 +++++- Strand/Screens/TodayView.swift | 274 ++++++--- Tools/i18n_audit_baseline.json | 72 --- Tools/i18n_echo_baseline.txt | 2 +- Tools/i18n_extra_locale_baseline.txt | 10 +- Tools/test_home_i18n.py | 522 ++++++++++++++++ .../com/noop/analytics/ReadinessEngine.kt | 140 +++-- .../com/noop/analytics/RecoveryDrivers.kt | 139 +++-- .../com/noop/analytics/StepsEstimateEngine.kt | 47 +- .../main/java/com/noop/ui/AutoWorkoutNudge.kt | 54 +- .../main/java/com/noop/ui/CoupledScreen.kt | 10 +- .../main/java/com/noop/ui/DashboardCards.kt | 34 +- .../src/main/java/com/noop/ui/HealthScreen.kt | 6 +- .../com/noop/ui/HealthVitalDetailLogic.kt | 2 +- .../main/java/com/noop/ui/KeyMetricPrefs.kt | 24 +- .../java/com/noop/ui/ScoringGuideScreen.kt | 16 +- .../com/noop/ui/StepsCalibrationScreen.kt | 19 +- .../main/java/com/noop/ui/TodayLayoutPrefs.kt | 26 +- .../java/com/noop/ui/TodayMetricsLogic.kt | 21 +- .../main/java/com/noop/ui/TodayProvenance.kt | 105 ++-- .../src/main/java/com/noop/ui/TodayScoring.kt | 95 ++- .../src/main/java/com/noop/ui/TodayScreen.kt | 567 ++++++++++++------ .../app/src/main/res/values-de/strings.xml | 298 +++++++++ .../app/src/main/res/values-es/strings.xml | 300 ++++++++- .../app/src/main/res/values-fr/strings.xml | 298 +++++++++ .../app/src/main/res/values-pl/strings.xml | 312 +++++++++- .../src/main/res/values-pt-rPT/strings.xml | 300 ++++++++- .../app/src/main/res/values-zh/strings.xml | 295 ++++++++- android/app/src/main/res/values/strings.xml | 298 +++++++++ .../com/noop/analytics/RD1LogDomainTest.kt | 5 +- .../com/noop/analytics/RecoveryDriversTest.kt | 93 +-- .../analytics/RecoverySaturationGuardTest.kt | 20 +- .../noop/analytics/StepsEstimateEngineTest.kt | 24 +- .../src/test/java/com/noop/ui/HrWindowTest.kt | 9 +- .../java/com/noop/ui/RecoveryDriversUiTest.kt | 7 +- .../com/noop/ui/TodayChargeTapCollapseTest.kt | 25 +- .../com/noop/ui/TodayExplainabilityTest.kt | 129 ++-- .../java/com/noop/ui/TodayMetricTilesTest.kt | 30 +- .../com/noop/ui/VitalReadingsTableTest.kt | 12 +- 47 files changed, 4558 insertions(+), 965 deletions(-) create mode 100644 Tools/test_home_i18n.py diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift index a4e1233eb9..03becd94f9 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift @@ -110,6 +110,18 @@ public enum IllnessSignalEngine { case alreadyUnwell // user logged feeling unwell — "rest up", not a scare } + /// Semantic presentation state. The UI localizes this enum; it never has to infer meaning from + /// the engine's English notification sentence. + public enum Message: String, Equatable, Sendable, Codable { + case learningBaseline, alreadyUnwellAgree, alreadyUnwell, normal, suppressed, mild, raised + } + + /// Locale-free confounder identity for presentation. `suppressedBy` remains the compatible + /// human-readable payload used by existing callers; new UIs should localize these typed values. + public enum SuppressionReason: String, Equatable, Sendable, Codable { + case alcohol, stress, sauna, hardOrLateWorkout, travel + } + public struct Result: Equatable, Sendable { /// 0–100 composite anomaly score (post-dampening for the suppressed level so the surface matches). public let score: Double @@ -119,15 +131,20 @@ public enum IllnessSignalEngine { public let firedSignals: [String] /// Named confounders that were present and damped/explained the score, e.g. "alcohol", "travel". public let suppressedBy: [String] + public let suppressionReasons: [SuppressionReason] /// Count of signals over the firing threshold (corroboration), regardless of level. public let signalCount: Int /// One-line non-clinical copy, terminating in the shipped not-a-diagnosis framing where it raises. public let copy: String + public let message: Message? public init(score: Double, level: Level, firedSignals: [String], suppressedBy: [String], - signalCount: Int, copy: String) { + signalCount: Int, copy: String, message: Message? = nil, + suppressionReasons: [SuppressionReason] = []) { self.score = score; self.level = level; self.firedSignals = firedSignals self.suppressedBy = suppressedBy; self.signalCount = signalCount; self.copy = copy + self.message = message + self.suppressionReasons = suppressionReasons } } @@ -170,7 +187,8 @@ public enum IllnessSignalEngine { if !context.baselineTrusted { return Result(score: score, level: .quiet, firedSignals: firedSignals, suppressedBy: [], signalCount: signalCount, - copy: "Still learning your baseline - keeping an eye out.") + copy: "Still learning your baseline - keeping an eye out.", + message: .learningBaseline) } // Already-unwell path: the user told us. Switch from "early warning" to a gentle "rest up" and @@ -181,24 +199,30 @@ public enum IllnessSignalEngine { ? "Rest up - you logged feeling unwell, and your numbers agree. \(disclaimerTail)" : "Rest up - you logged feeling unwell. Take it easy today. \(disclaimerTail)" return Result(score: score, level: .alreadyUnwell, firedSignals: firedSignals, - suppressedBy: [], signalCount: signalCount, copy: copy) + suppressedBy: [], signalCount: signalCount, copy: copy, + message: agreeing ? .alreadyUnwellAgree : .alreadyUnwell) } // Corroboration + magnitude gate: need ≥ 2 firing signals and a mild-or-better composite, else quiet. guard signalCount >= minCorroboratingSignals, score >= mildThreshold else { return Result(score: score, level: .quiet, firedSignals: firedSignals, suppressedBy: [], signalCount: signalCount, - copy: "Nothing notable - your signals look like your normal range.") + copy: "Nothing notable - your signals look like your normal range.", + message: .normal) } // Confounder suppression — the differentiating part. Collect every present behaviour/travel tag // that offers a plainer explanation; if any are present, dampen the score and downgrade. var suppressedBy: [String] = [] - if context.alcohol { suppressedBy.append("alcohol") } - if context.stress { suppressedBy.append("stress") } - if context.sauna { suppressedBy.append("sauna") } - if context.hardOrLateWorkout { suppressedBy.append("a hard or late workout") } - if context.travelPhaseJump { suppressedBy.append("travel") } + var suppressionReasons: [SuppressionReason] = [] + if context.alcohol { suppressedBy.append("alcohol"); suppressionReasons.append(.alcohol) } + if context.stress { suppressedBy.append("stress"); suppressionReasons.append(.stress) } + if context.sauna { suppressedBy.append("sauna"); suppressionReasons.append(.sauna) } + if context.hardOrLateWorkout { + suppressedBy.append("a hard or late workout") + suppressionReasons.append(.hardOrLateWorkout) + } + if context.travelPhaseJump { suppressedBy.append("travel"); suppressionReasons.append(.travel) } let signalsPhrase = firedSignals.isEmpty ? "Some signals are up" : firedSignals.joined(separator: ", ") @@ -208,7 +232,8 @@ public enum IllnessSignalEngine { let copy = "Some signals are up (\(signalsPhrase)), but you logged \(reason) - likely that, " + "not illness. \(disclaimerTail)" return Result(score: dampened, level: .suppressed, firedSignals: firedSignals, - suppressedBy: suppressedBy, signalCount: signalCount, copy: copy) + suppressedBy: suppressedBy, signalCount: signalCount, copy: copy, + message: .suppressed, suppressionReasons: suppressionReasons) } // No confounder. Mild stays in the detail view; a strong composite raises. @@ -216,14 +241,14 @@ public enum IllnessSignalEngine { let copy = "A few signals are mildly up (\(signalsPhrase)). Nothing alarming - worth a calmer " + "day. \(disclaimerTail)" return Result(score: score, level: .mild, firedSignals: firedSignals, - suppressedBy: [], signalCount: signalCount, copy: copy) + suppressedBy: [], signalCount: signalCount, copy: copy, message: .mild) } let ruledOut = "no alcohol or travel logged" let copy = "Heads-up - your body looks strained. \(signalsPhrase). With \(ruledOut), consider " + "taking it easy. \(disclaimerTail)" return Result(score: score, level: .raised, firedSignals: firedSignals, - suppressedBy: [], signalCount: signalCount, copy: copy) + suppressedBy: [], signalCount: signalCount, copy: copy, message: .raised) } // MARK: - Helpers diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift index 477484dd5d..f1e05b9d5f 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift @@ -36,15 +36,24 @@ public enum ReadinessEngine { case good, neutral, watch, bad } + /// Locale-free numeric evidence. Clients format and localize it at the display boundary. + public enum Evidence: Sendable, Equatable { + case metric(value: Double, baseline: Double, unit: String, decimals: Int) + case trainingLoad(acute: Double, chronic: Double) + case monotony(Double) + } + public struct Signal: Sendable, Equatable { public let key: String // "hrv" | "rhr" | "respRate" | "acwr" | "monotony" public let label: String // short human label public let evidence: String? + public let evidenceData: Evidence? public let detail: String // one-line plain-English read public let flag: Flag - public init(key: String, label: String, evidence: String? = nil, detail: String, flag: Flag) { + public init(key: String, label: String, evidence: String? = nil, + evidenceData: Evidence? = nil, detail: String, flag: Flag) { self.key = key; self.label = label; self.evidence = evidence - self.detail = detail; self.flag = flag + self.evidenceData = evidenceData; self.detail = detail; self.flag = flag } } @@ -180,10 +189,12 @@ public enum ReadinessEngine { if z >= 2.0 { signals.append(Signal(key: "respRate", label: "Respiratory rate", evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1), + evidenceData: .metric(value: rr, baseline: m, unit: "rpm", decimals: 1), detail: "up vs baseline - sometimes an early sign of getting sick", flag: .bad)) } else if z >= 1.5 { signals.append(Signal(key: "respRate", label: "Respiratory rate", evidence: evidence(value: rr, baseline: m, unit: "rpm", decimals: 1), + evidenceData: .metric(value: rr, baseline: m, unit: "rpm", decimals: 1), detail: "slightly raised vs baseline", flag: .watch)) } } @@ -209,6 +220,7 @@ public enum ReadinessEngine { if mono >= 2.0 { signals.append(Signal(key: "monotony", label: "Training variety", evidence: "monotony \(String(format: "%.1f", mono))", + evidenceData: .monotony(mono), detail: "low - similar strain every day raises strain/illness risk", flag: .watch)) } } @@ -271,6 +283,8 @@ public enum ReadinessEngine { return Signal(key: key, label: label, evidence: evidence(value: v, baseline: logDomain ? exp(m) : m, unit: unit, decimals: decimals), + evidenceData: .metric(value: v, baseline: logDomain ? exp(m) : m, + unit: unit, decimals: decimals), detail: text, flag: flag) } @@ -281,18 +295,22 @@ public enum ReadinessEngine { case ..<0.8: return Signal(key: "acwr", label: "Training load", evidence: evidence, + evidenceData: .trainingLoad(acute: acute, chronic: chronic), detail: "ramping down (acute:chronic \(pct)) - room to build", flag: .watch) case 0.8..<1.3: return Signal(key: "acwr", label: "Training load", evidence: evidence, + evidenceData: .trainingLoad(acute: acute, chronic: chronic), detail: "in the sweet spot (acute:chronic \(pct))", flag: .good) case 1.3..<1.5: return Signal(key: "acwr", label: "Training load", evidence: evidence, + evidenceData: .trainingLoad(acute: acute, chronic: chronic), detail: "building fast (acute:chronic \(pct)) - watch fatigue", flag: .watch) default: return Signal(key: "acwr", label: "Training load", evidence: evidence, + evidenceData: .trainingLoad(acute: acute, chronic: chronic), detail: "spiking (acute:chronic \(pct)) - higher injury risk", flag: .bad) } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift b/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift index 5a5319d9b3..1c64257531 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift @@ -18,6 +18,7 @@ public struct DayNavBar: View { private let onSelect: (Int) -> Void @State private var showingPicker = false + @Environment(\.locale) private var locale /// `today` is the caller's LOGICAL day (the same anchor the rest of Today uses, rolling at 04:00), /// so every label here counts back from it. Passing it in instead of reading `Date()` keeps the @@ -40,7 +41,10 @@ public struct DayNavBar: View { switch selectedOffset { case 0: return "Today" case 1: return "Yesterday" - default: return "\(Self.dayFmt.string(from: selectedDay))" + default: + return "\(selectedDay.formatted( + .dateTime.weekday(.abbreviated).day().month(.abbreviated).locale(locale) + ))" } } @@ -67,7 +71,9 @@ public struct DayNavBar: View { // On today the label already reads "Today"; the full date would just duplicate the // header, so it's shown only once you've navigated to another day (for orientation). if selectedOffset > 0 { - Text(Self.fullDateFmt.string(from: selectedDay)) + Text(selectedDay.formatted( + .dateTime.day().month(.abbreviated).year().locale(locale) + )) .font(StrandFont.captionNumber) .foregroundStyle(StrandPalette.accent) .lineLimit(1) @@ -83,7 +89,7 @@ public struct DayNavBar: View { .overlay(blockShape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) } .buttonStyle(.plain) - .accessibilityLabel("Pick a date") + .accessibilityLabel(Text("Pick a date", bundle: .module)) .popover(isPresented: $showingPicker) { datePickerPopover } @@ -143,11 +149,5 @@ public struct DayNavBar: View { #endif } - private static let dayFmt: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "EEE d MMM"; f.locale = Locale(identifier: "en_US_POSIX"); return f - }() - private static let fullDateFmt: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "d MMM yyyy"; f.locale = Locale(identifier: "en_US_POSIX"); return f - }() } #endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings index 7d0c41a735..df894b30cd 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings +++ b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings @@ -4197,6 +4197,70 @@ } } }, + "Pick a date": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Datum auswählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pick a date" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir una fecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir une date" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli una data" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Selecionar uma data" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать дату" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择日期" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇日期" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybierz datę" + } + } + } + }, "Previous day": { "localizations": { "de": { diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index f7f69c4b35..a46b65246e 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -114,8 +114,12 @@ final class AppModel: ObservableObject { var avgHr: Int = 0 var peakHr: Int = 0 } + struct HealthAlert: Equatable { + let message: IllnessSignalEngine.Message + let firedSignals: [String] + } /// Illness/strain early-warning (recent RHR up + HRV down + skin-temp up vs baseline). nil = clear. - @Published var healthAlert: String? + @Published var healthAlert: HealthAlert? // MARK: - v5 pillar snapshot (engines run in the analytics pass; the views read these) // @@ -1564,7 +1568,7 @@ final class AppModel: ObservableObject { } /// Run the `IllnessSignalEngine` from the day history + the journal-derived confounder context, then - /// publish the result + the legacy `healthAlert` banner string (kept for the existing banner surface). + /// publish the result + the semantic `healthAlert` banner payload. private func applyIllnessSignal(_ days: [DailyMetric], alcohol: Bool, hardOrLateWorkout: Bool, alreadyUnwell: Bool) { let previous = healthAlert @@ -1625,25 +1629,38 @@ final class AppModel: ObservableObject { // Caller-rendered phrases for the signals that fire (the engine surfaces only the firing ones). var labels: [String: String] = [:] if let r = rm({ $0.restingHr.map(Double.init) }), let b = mean(base.compactMap { $0.restingHr.map(Double.init) }), r > b { - labels["restingHR"] = "RHR +\(Int((r - b).rounded()))" + let delta = Int((r - b).rounded()) + labels["restingHR"] = String(localized: "RHR +\(delta)") } if let r = rm({ $0.avgHrv }), let b = mean(base.compactMap { $0.avgHrv }), b > 0, r < b { - labels["hrv"] = "HRV −\(Int(((1 - r / b) * 100).rounded()))%" + let percent = Int(((1 - r / b) * 100).rounded()) + labels["hrv"] = String(localized: "HRV −\(percent)%") } if let r = rm({ $0.skinTempDevC }), r > 0 { - labels["skinTemp"] = "skin temp +\(String(format: "%.1f", r)) °C" + let temperature = String(format: "%.1f", locale: AppLanguage.activeLocale, r) + labels["skinTemp"] = String(localized: "Skin temperature +\(temperature) °C") } if let r = rm({ $0.respRateBpm }), let b = mean(base.compactMap { $0.respRateBpm }), r > b { - labels["respiration"] = "respiration up" + labels["respiration"] = String(localized: "Respiration up") } let result = IllnessSignalEngine.evaluate(inputs, context: context, firedLabels: labels) illnessSignal = result - // The amber banner string reflects the raised / already-unwell levels only (the calmer levels + // The amber banner payload reflects the raised / already-unwell levels only (the calmer levels // surface in the Health hub's Heads-Up card, never as a scary banner). - healthAlert = (result.level == .raised || result.level == .alreadyUnwell) ? result.copy : nil - if let alert = healthAlert, previous == nil { - IllnessNotifier.post(alert) + switch result.level { + case .raised: + healthAlert = HealthAlert(message: result.message ?? .raised, + firedSignals: result.firedSignals) + case .alreadyUnwell: + healthAlert = HealthAlert(message: result.message ?? .alreadyUnwell, + firedSignals: result.firedSignals) + case .quiet, .mild, .suppressed: + healthAlert = nil + } + if healthAlert != nil, previous == nil { + // Notifications retain their established copy contract; Home renders the semantic result. + IllnessNotifier.post(result.copy) } } diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index cd1d721ee1..71f73a2af1 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -971,7 +971,10 @@ struct LiquidTodayView: View { Text("SYNTHESIS").font(StrandFont.overline).tracking(1.6) .foregroundStyle(StrandPalette.textSecondary) Spacer() - Text(synthesisExpanded ? "hide" : "show").font(StrandFont.caption) + Text(synthesisExpanded + ? String(localized: "hide") + : String(localized: "show")) + .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) } // While the baseline calibrates, the honest "N of 4 nights" progress replaces the @@ -1137,7 +1140,7 @@ struct LiquidTodayView: View { ktile(String(localized: "Blood Oxygen"), icon: keyMetricIcon(metric), intText(spo2), "%", StrandPalette.metricCyan, fracOver(spo2, 100), key: "spo2") case .respiratory: let resp = displayDay?.respRateBpm ?? vitalsDay?.respRateBpm ?? respDay?.respRateBpm - ktile(String(localized: "Respiratory"), icon: keyMetricIcon(metric), resp.map { String(format: "%.1f", $0) } ?? "—", "rpm", StrandPalette.accent, fracOver(resp, 24), key: "resp_rate") + 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") case .steps: ktile(String(localized: "Steps"), icon: keyMetricIcon(metric), stepsText, "", StrandPalette.chargeColor, fracOver(stepCount, 10000), key: stepsDetailKey, detailMetric: stepsDetailMetric) @@ -1168,6 +1171,7 @@ struct LiquidTodayView: View { private func ktile(_ label: String, icon: String, _ value: String, _ unit: String, _ tint: Color, _ frac: Double?, key: String? = nil, detailMetric: MetricDescriptor? = nil) -> some View { + let displayValue = unit.isEmpty ? value : (unit == "%" ? value + unit : value + " " + unit) let tile = VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Image(systemName: icon) @@ -1181,9 +1185,8 @@ struct LiquidTodayView: View { .lineLimit(1) .minimumScaleFactor(0.82) } - (Text(value).font(StrandFont.number(24)) - + Text(unit.isEmpty ? "" : (unit == "%" ? unit : " \(unit)")) - .font(StrandFont.number(24))) + Text(verbatim: displayValue) + .font(StrandFont.number(24)) .foregroundStyle(StrandPalette.textPrimary) .lineLimit(1) .minimumScaleFactor(0.75) @@ -1614,11 +1617,11 @@ struct LiquidTodayView: View { private func unitText(_ v: Double?, _ unit: String, decimals: Int = 0) -> String { guard let v else { return "–" } - let n = decimals > 0 ? String(format: "%.\(decimals)f", v) : String(Int(v.rounded())) + let n = decimals > 0 ? String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, v) : String(Int(v.rounded())) return unit.isEmpty ? n : "\(n) \(unit)" } - private var stressText: String { stress.map { String(Int($0.rounded())) } ?? "Calibrating" } + private var stressText: String { stress.map { String(Int($0.rounded())) } ?? String(localized: "Calibrating") } private var sleepText: String { guard let m = displayDay?.totalSleepMin else { return "–" } @@ -1648,7 +1651,7 @@ struct LiquidTodayView: View { var parts: [String] = [] let secs = w.durationS ?? Double(max(w.endTs - w.startTs, 0)) parts.append("\(Int(secs / 60)) min") - if let dm = w.distanceM, dm > 0 { parts.append(String(format: "%.1f km", dm / 1000)) } + if let dm = w.distanceM, dm > 0 { parts.append(String(format: "%.1f km", locale: AppLanguage.activeLocale, dm / 1000)) } if let k = w.energyKcal { parts.append("\(Int(k.rounded())) kcal") } return parts.joined(separator: " · ") } @@ -1787,7 +1790,7 @@ private struct HeroScoreCell: View { .foregroundStyle(StrandPalette.textSecondary) } .buttonStyle(.plain) - .accessibilityLabel(Text("\(label), \(score.map { decimals > 0 ? String(format: "%.\(decimals)f", $0) : String(Int($0.rounded())) } ?? String(localized: "no data yet")). See how it is scored.")) + .accessibilityLabel(Text("\(label), \(score.map { decimals > 0 ? String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, $0) : String(Int($0.rounded())) } ?? String(localized: "no data yet")). See how it is scored.")) } .frame(maxWidth: .infinity) } @@ -2034,7 +2037,9 @@ private struct LiquidLiveHR: View { stat(String(localized: "Max"), series.max()) } } else { - Text(live.connected ? "Waiting for a live heartbeat…" : "Connect your strap to see live heart rate") + Text(live.connected + ? String(localized: "Waiting for a live heartbeat…") + : String(localized: "Connect your strap to see live heart rate")) .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) @@ -2468,7 +2473,7 @@ private struct LiquidStrapBatteryRow: View { /// Mac / Android pill and the classic Today badge. private func batteryText(pct: Double) -> String { let base = "\(Int(pct.rounded()))%" - if live.charging == true { return "\(base) · Charging" } + if live.charging == true { return "\(base) · \(String(localized: "Charging"))" } if let est = estimateText { return "\(base) · \(est)" } return base } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 22e904c101..9dc6168cda 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,279 @@ { "sourceLanguage": "en", "strings": { + "RHR +%lld": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Ruhepuls +%lld" } }, "en": { "stringUnit": { "state": "translated", "value": "RHR +%lld" } }, "es": { "stringUnit": { "state": "translated", "value": "FC en reposo +%lld" } }, "fr": { "stringUnit": { "state": "translated", "value": "FC au repos +%lld" } }, "it": { "stringUnit": { "state": "translated", "value": "FC a riposo +%lld" } }, "pl": { "stringUnit": { "state": "translated", "value": "Tętno spocz. +%lld" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "FC em repouso +%lld" } }, "ru": { "stringUnit": { "state": "translated", "value": "Пульс в покое +%lld" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "静息心率 +%lld" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "靜息心率 +%lld" } } + } }, + "HRV −%lld%%": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "en": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "es": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "fr": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "it": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "pl": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "VFC −%lld%%" } }, "ru": { "stringUnit": { "state": "translated", "value": "ВСР −%lld%%" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "HRV −%lld%%" } } + } }, + "Skin temperature +%@ °C": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Hauttemperatur +%@ °C" } }, "en": { "stringUnit": { "state": "translated", "value": "Skin temperature +%@ °C" } }, "es": { "stringUnit": { "state": "translated", "value": "Temperatura de la piel +%@ °C" } }, "fr": { "stringUnit": { "state": "translated", "value": "Température cutanée +%@ °C" } }, "it": { "stringUnit": { "state": "translated", "value": "Temperatura cutanea +%@ °C" } }, "pl": { "stringUnit": { "state": "translated", "value": "Temperatura skóry +%@ °C" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Temperatura da pele +%@ °C" } }, "ru": { "stringUnit": { "state": "translated", "value": "Температура кожи +%@ °C" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "皮肤温度 +%@ °C" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "皮膚溫度 +%@ °C" } } + } }, + "Respiration up": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Atemfrequenz erhöht" } }, "en": { "stringUnit": { "state": "translated", "value": "Respiration up" } }, "es": { "stringUnit": { "state": "translated", "value": "Respiración elevada" } }, "fr": { "stringUnit": { "state": "translated", "value": "Respiration élevée" } }, "it": { "stringUnit": { "state": "translated", "value": "Respirazione elevata" } }, "pl": { "stringUnit": { "state": "translated", "value": "Oddech podwyższony" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Respiração elevada" } }, "ru": { "stringUnit": { "state": "translated", "value": "Частота дыхания повышена" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "呼吸频率升高" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "呼吸頻率升高" } } + } }, + "%@ vs %@ %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "%1$@ vs. %2$@ %3$@" } }, "en": { "stringUnit": { "state": "translated", "value": "%@ vs %@ %@" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$@ frente a %2$@ %3$@" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$@ contre %2$@ %3$@" } }, "it": { "stringUnit": { "state": "translated", "value": "%1$@ rispetto a %2$@ %3$@" } }, "pl": { "stringUnit": { "state": "translated", "value": "%1$@ vs %2$@ %3$@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "%1$@ vs. %2$@ %3$@" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$@ против %2$@ %3$@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 对比 %2$@ %3$@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%1$@ 對比 %2$@ %3$@" } } + } }, + "7d %@ / 28d %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "7 T %@ / 28 T %@" } }, "en": { "stringUnit": { "state": "translated", "value": "7d %@ / 28d %@" } }, "es": { "stringUnit": { "state": "translated", "value": "7 d %@ / 28 d %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "7 j %@ / 28 j %@" } }, "it": { "stringUnit": { "state": "translated", "value": "7 g %@ / 28 g %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "7 d %@ / 28 d %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "7 d %@ / 28 d %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "7 д %@ / 28 д %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "7 天 %@ / 28 天 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "7 天 %@ / 28 天 %@" } } + } }, + "monotony %@": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Monotonie %@" } }, "en": { "stringUnit": { "state": "translated", "value": "monotony %@" } }, "es": { "stringUnit": { "state": "translated", "value": "monotonía %@" } }, "fr": { "stringUnit": { "state": "translated", "value": "monotonie %@" } }, "it": { "stringUnit": { "state": "translated", "value": "monotonia %@" } }, "pl": { "stringUnit": { "state": "translated", "value": "monotonia %@" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "monotonia %@" } }, "ru": { "stringUnit": { "state": "translated", "value": "монотонность %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "单调度 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "單調度 %@" } } + } }, + "Your body looks strained. Signals up: %@. No alcohol or travel was logged, so consider taking it easy. On-device estimate, not a diagnosis.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Körper wirkt belastet. Auffällige Signale: %@. Da weder Alkohol noch Reisen protokolliert wurden, solltest du es ruhiger angehen. Schätzung auf dem Gerät, keine Diagnose." } }, "en": { "stringUnit": { "state": "translated", "value": "Your body looks strained. Signals up: %@. No alcohol or travel was logged, so consider taking it easy. On-device estimate, not a diagnosis." } }, "es": { "stringUnit": { "state": "translated", "value": "Tu cuerpo parece estar bajo presión. Señales elevadas: %@. Como no has registrado alcohol ni viajes, plantéate tomártelo con calma. Estimación en el dispositivo, no es un diagnóstico." } }, "fr": { "stringUnit": { "state": "translated", "value": "Votre corps semble éprouvé. Signaux élevés : %@. Comme aucun alcool ni voyage n’a été enregistré, envisagez de lever le pied. Estimation sur l’appareil, pas un diagnostic." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O teu corpo parece sob esforço. Sinais elevados: %@. Como não registaste álcool nem viagens, considera abrandar. Estimativa no dispositivo, não é um diagnóstico." } }, + "it": { "stringUnit": { "state": "translated", "value": "Il tuo corpo sembra sotto sforzo. Segnali elevati: %@. Non risultano alcol o viaggi registrati, quindi considera di prendertela con calma. Stima sul dispositivo, non è una diagnosi." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Twój organizm wygląda na przeciążony. Podwyższone sygnały: %@. Nie zarejestrowano alkoholu ani podróży, więc rozważ spokojniejszy dzień. Ocena na urządzeniu, nie diagnoza." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Похоже, организм испытывает нагрузку. Повышенные показатели: %@. Алкоголь и поездки не отмечены, поэтому постарайтесь снизить нагрузку. Оценка на устройстве, не диагноз." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "你的身体似乎承受着较大压力。升高的信号:%@。未记录饮酒或旅行,因此请考虑放松一些。设备端估算,不是诊断。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "你的身體似乎承受著較大壓力。升高的訊號:%@。未記錄飲酒或旅行,因此請考慮放鬆一些。裝置端估算,不是診斷。" } } + } }, + "You logged feeling unwell, and your signals agree. Take it easy today. On-device estimate, not a diagnosis.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast angegeben, dass du dich unwohl fühlst, und deine Signale passen dazu. Geh es heute ruhig an. Schätzung auf dem Gerät, keine Diagnose." } }, "en": { "stringUnit": { "state": "translated", "value": "You logged feeling unwell, and your signals agree. Take it easy today. On-device estimate, not a diagnosis." } }, "es": { "stringUnit": { "state": "translated", "value": "Has registrado que no te encuentras bien y tus señales coinciden. Tómatelo con calma hoy. Estimación en el dispositivo, no es un diagnóstico." } }, "fr": { "stringUnit": { "state": "translated", "value": "Vous avez indiqué ne pas vous sentir bien et vos signaux concordent. Levez le pied aujourd’hui. Estimation sur l’appareil, pas un diagnostic." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Registaste que não te sentes bem e os teus sinais estão de acordo. Tem calma hoje. Estimativa no dispositivo, não é um diagnóstico." } }, + "it": { "stringUnit": { "state": "translated", "value": "Hai registrato di non sentirti bene e i tuoi segnali lo confermano. Riposati oggi. Stima sul dispositivo, non è una diagnosi." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Zarejestrowano złe samopoczucie, a sygnały organizmu to potwierdzają. Dziś zwolnij tempo. Ocena na urządzeniu, nie diagnoza." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Вы отметили плохое самочувствие, и показатели это подтверждают. Сегодня снизьте нагрузку. Оценка на устройстве, не диагноз." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "你记录了身体不适,相关信号也与之吻合。今天请放松休息。设备端估算,不是诊断。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "你記錄了身體不適,相關訊號也與之吻合。今天請放鬆休息。裝置端估算,不是診斷。" } } + } }, + "You logged feeling unwell. Take it easy today. On-device estimate, not a diagnosis.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Du hast angegeben, dass du dich unwohl fühlst. Geh es heute ruhig an. Schätzung auf dem Gerät, keine Diagnose." } }, "en": { "stringUnit": { "state": "translated", "value": "You logged feeling unwell. Take it easy today. On-device estimate, not a diagnosis." } }, "es": { "stringUnit": { "state": "translated", "value": "Has registrado que no te encuentras bien. Tómatelo con calma hoy. Estimación en el dispositivo, no es un diagnóstico." } }, "fr": { "stringUnit": { "state": "translated", "value": "Vous avez indiqué ne pas vous sentir bien. Levez le pied aujourd’hui. Estimation sur l’appareil, pas un diagnostic." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Registaste que não te sentes bem. Tem calma hoje. Estimativa no dispositivo, não é um diagnóstico." } }, + "it": { "stringUnit": { "state": "translated", "value": "Hai registrato di non sentirti bene. Riposati oggi. Stima sul dispositivo, non è una diagnosi." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Zarejestrowano złe samopoczucie. Dziś zwolnij tempo. Ocena na urządzeniu, nie diagnoza." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Вы отметили плохое самочувствие. Сегодня снизьте нагрузку. Оценка на устройстве, не диагноз." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "你记录了身体不适。今天请放松休息。设备端估算,不是诊断。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "你記錄了身體不適。今天請放鬆休息。裝置端估算,不是診斷。" } } + } }, + "Some signals are up, but you logged %@. That is the more likely explanation. On-device estimate, not a diagnosis.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Einige Signale sind erhöht, aber du hast %@ protokolliert. Das ist die wahrscheinlichere Erklärung. Schätzung auf dem Gerät, keine Diagnose." } }, "en": { "stringUnit": { "state": "translated", "value": "Some signals are up, but you logged %@. That is the more likely explanation. On-device estimate, not a diagnosis." } }, "es": { "stringUnit": { "state": "translated", "value": "Algunas señales están elevadas, pero has registrado %@. Esa es la explicación más probable. Estimación en el dispositivo, no es un diagnóstico." } }, "fr": { "stringUnit": { "state": "translated", "value": "Certains signaux sont élevés, mais vous avez enregistré %@. C’est l’explication la plus probable. Estimation sur l’appareil, pas un diagnostic." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Alguns sinais estão elevados, mas registaste %@. Essa é a explicação mais provável. Estimativa no dispositivo, não é um diagnóstico." } }, + "it": { "stringUnit": { "state": "translated", "value": "Alcuni segnali sono elevati, ma hai registrato %@. Questa è la spiegazione più probabile. Stima sul dispositivo, non è una diagnosi." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Niektóre sygnały są podwyższone, ale zarejestrowano %@. To najbardziej prawdopodobne wyjaśnienie. Ocena na urządzeniu, nie diagnoza." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Некоторые показатели повышены, но отмечено: %@. Скорее всего, причина именно в этом. Оценка на устройстве, не диагноз." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "部分信号升高,但你记录了%@。这更可能是原因。设备端估算,不是诊断。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "部分訊號升高,但你記錄了%@。這更可能是原因。裝置端估算,不是診斷。" } } + } }, + "A few signals are mildly up: %@. Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Einige Signale sind leicht erhöht: %@. Kein Grund zur Sorge, aber ein ruhigerer Tag könnte helfen. Schätzung auf dem Gerät, keine Diagnose." } }, "en": { "stringUnit": { "state": "translated", "value": "A few signals are mildly up: %@. Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis." } }, "es": { "stringUnit": { "state": "translated", "value": "Algunas señales están ligeramente elevadas: %@. No es alarmante, pero un día más tranquilo podría ayudar. Estimación en el dispositivo, no es un diagnóstico." } }, "fr": { "stringUnit": { "state": "translated", "value": "Quelques signaux sont légèrement élevés : %@. Rien d’alarmant, mais une journée plus calme peut aider. Estimation sur l’appareil, pas un diagnostic." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Alguns sinais estão ligeiramente elevados: %@. Não é alarmante, mas um dia mais calmo pode ajudar. Estimativa no dispositivo, não é um diagnóstico." } }, + "it": { "stringUnit": { "state": "translated", "value": "Alcuni segnali sono leggermente elevati: %@. Nulla di allarmante, ma una giornata più tranquilla può aiutare. Stima sul dispositivo, non è una diagnosi." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Kilka sygnałów jest lekko podwyższonych: %@. Nie ma powodów do niepokoju, ale spokojniejszy dzień może pomóc. Ocena na urządzeniu, nie diagnoza." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Несколько показателей немного повышены: %@. Ничего тревожного, но более спокойный день может помочь. Оценка на устройстве, не диагноз." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "有几个信号略有升高:%@。无需担心,但轻松度过一天可能会有帮助。设备端估算,不是诊断。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "有幾個訊號略有升高:%@。無需擔心,但輕鬆度過一天可能會有幫助。裝置端估算,不是診斷。" } } + } }, + "Still learning your baseline and keeping an eye on your signals.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Ausgangswert wird noch gelernt und deine Signale werden weiter beobachtet." } }, "en": { "stringUnit": { "state": "translated", "value": "Still learning your baseline and keeping an eye on your signals." } }, "es": { "stringUnit": { "state": "translated", "value": "Todavía estamos aprendiendo tu valor de referencia y observando tus señales." } }, "fr": { "stringUnit": { "state": "translated", "value": "Votre valeur de référence est encore en cours d’apprentissage et vos signaux restent sous surveillance." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda estamos a aprender o teu valor de referência e a acompanhar os teus sinais." } }, + "it": { "stringUnit": { "state": "translated", "value": "Stiamo ancora imparando i tuoi valori di riferimento e tenendo d’occhio i tuoi segnali." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Nadal uczymy się Twoich wartości bazowych i obserwujemy sygnały organizmu." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Мы всё ещё изучаем ваши базовые значения и следим за показателями." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "仍在了解你的基线并持续关注相关信号。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "仍在瞭解你的基線並持續關注相關訊號。" } } + } }, + "Nothing notable. Your signals look like their normal range.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nichts Auffälliges. Deine Signale liegen im normalen Bereich." } }, "en": { "stringUnit": { "state": "translated", "value": "Nothing notable. Your signals look like their normal range." } }, "es": { "stringUnit": { "state": "translated", "value": "Nada destacable. Tus señales parecen estar dentro de su rango normal." } }, "fr": { "stringUnit": { "state": "translated", "value": "Rien de notable. Vos signaux semblent dans leur plage habituelle." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Nada de relevante. Os teus sinais parecem estar dentro do intervalo normal." } }, + "it": { "stringUnit": { "state": "translated", "value": "Nulla di rilevante. I tuoi segnali sembrano rientrare nell’intervallo normale." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Nic szczególnego. Sygnały organizmu mieszczą się w typowym zakresie." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Ничего необычного. Показатели находятся в обычном диапазоне." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "没有明显异常。你的信号处于正常范围内。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "沒有明顯異常。你的訊號處於正常範圍內。" } } + } }, + "Sauna": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Sauna" } }, "en": { "stringUnit": { "state": "translated", "value": "Sauna" } }, "es": { "stringUnit": { "state": "translated", "value": "sauna" } }, "fr": { "stringUnit": { "state": "translated", "value": "sauna" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "sauna" } }, + "it": { "stringUnit": { "state": "translated", "value": "sauna" } }, + "pl": { "stringUnit": { "state": "translated", "value": "sauna" } }, + "ru": { "stringUnit": { "state": "translated", "value": "sauna" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "sauna" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "sauna" } } + } }, + "Travel": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Reise" } }, "en": { "stringUnit": { "state": "translated", "value": "travel" } }, "es": { "stringUnit": { "state": "translated", "value": "viaje" } }, "fr": { "stringUnit": { "state": "translated", "value": "voyage" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "viagem" } }, + "it": { "stringUnit": { "state": "translated", "value": "viagem" } }, + "pl": { "stringUnit": { "state": "translated", "value": "viagem" } }, + "ru": { "stringUnit": { "state": "translated", "value": "viagem" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "viagem" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "viagem" } } + } }, + "Hard or late workout": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "hartes oder spätes Training" } }, "en": { "stringUnit": { "state": "translated", "value": "hard or late workout" } }, "es": { "stringUnit": { "state": "translated", "value": "entrenamiento intenso o tardío" } }, "fr": { "stringUnit": { "state": "translated", "value": "entraînement intense ou tardif" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "treino intenso ou tardio" } }, + "it": { "stringUnit": { "state": "translated", "value": "treino intenso ou tardio" } }, + "pl": { "stringUnit": { "state": "translated", "value": "treino intenso ou tardio" } }, + "ru": { "stringUnit": { "state": "translated", "value": "treino intenso ou tardio" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "treino intenso ou tardio" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "treino intenso ou tardio" } } + } }, + "For awareness only. Not a medical device, not contraception, not a substitute for professional care.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Nur zur Orientierung. Kein Medizinprodukt, keine Verhütungsmethode und kein Ersatz für professionelle medizinische Beratung." } }, + "en": { "stringUnit": { "state": "translated", "value": "For awareness only. Not a medical device, not contraception, not a substitute for professional care." } }, + "es": { "stringUnit": { "state": "translated", "value": "Solo con fines informativos. No es un dispositivo médico ni un método anticonceptivo, ni sustituye la atención profesional." } }, + "fr": { "stringUnit": { "state": "translated", "value": "À titre informatif uniquement. Ce n’est ni un dispositif médical, ni un moyen de contraception, ni un substitut à un avis professionnel." } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "Apenas para fins informativos. Não é um dispositivo médico nem um método contracetivo, nem substitui cuidados profissionais." } }, + "it": { "stringUnit": { "state": "translated", "value": "Apenas para fins informativos. Não é um dispositivo médico nem um método contracetivo, nem substitui cuidados profissionais." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Apenas para fins informativos. Não é um dispositivo médico nem um método contracetivo, nem substitui cuidados profissionais." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Apenas para fins informativos. Não é um dispositivo médico nem um método contracetivo, nem substitui cuidados profissionais." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Apenas para fins informativos. Não é um dispositivo médico nem um método contracetivo, nem substitui cuidados profissionais." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Apenas para fins informativos. Não é um dispositivo médico nem um método contracetivo, nem substitui cuidados profissionais." } } + } }, + "Follicular range – temperature is near your baseline.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Follikuläre Phase – die Temperatur liegt nahe deinem Ausgangswert." } }, "en": { "stringUnit": { "state": "translated", "value": "Follicular range – temperature is near your baseline." } }, "es": { "stringUnit": { "state": "translated", "value": "Fase folicular: la temperatura está cerca de tu valor de referencia." } }, "fr": { "stringUnit": { "state": "translated", "value": "Phase folliculaire : la température est proche de votre valeur de référence." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Fase folicular: a temperatura está próxima do teu valor de referência." } }, + "it": { "stringUnit": { "state": "translated", "value": "Fase folicular: a temperatura está próxima do teu valor de referência." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Fase folicular: a temperatura está próxima do teu valor de referência." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Fase folicular: a temperatura está próxima do teu valor de referência." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Fase folicular: a temperatura está próxima do teu valor de referência." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Fase folicular: a temperatura está próxima do teu valor de referência." } } + } }, + "Around your mid-cycle shift – temperature is changing.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Um die Zyklusmitte – deine Temperatur verändert sich." } }, "en": { "stringUnit": { "state": "translated", "value": "Around your mid-cycle shift – temperature is changing." } }, "es": { "stringUnit": { "state": "translated", "value": "En torno al cambio de mitad del ciclo: tu temperatura está variando." } }, "fr": { "stringUnit": { "state": "translated", "value": "Autour du changement de milieu de cycle : votre température évolue." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Perto da mudança a meio do ciclo: a tua temperatura está a mudar." } }, + "it": { "stringUnit": { "state": "translated", "value": "Perto da mudança a meio do ciclo: a tua temperatura está a mudar." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Perto da mudança a meio do ciclo: a tua temperatura está a mudar." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Perto da mudança a meio do ciclo: a tua temperatura está a mudar." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Perto da mudança a meio do ciclo: a tua temperatura está a mudar." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Perto da mudança a meio do ciclo: a tua temperatura está a mudar." } } + } }, + "Luteal range – temperature is above your baseline.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Luteale Phase – die Temperatur liegt über deinem Ausgangswert." } }, "en": { "stringUnit": { "state": "translated", "value": "Luteal range – temperature is above your baseline." } }, "es": { "stringUnit": { "state": "translated", "value": "Fase lútea: la temperatura está por encima de tu valor de referencia." } }, "fr": { "stringUnit": { "state": "translated", "value": "Phase lutéale : la température est supérieure à votre valeur de référence." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Fase lútea: a temperatura está acima do teu valor de referência." } }, + "it": { "stringUnit": { "state": "translated", "value": "Fase lútea: a temperatura está acima do teu valor de referência." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Fase lútea: a temperatura está acima do teu valor de referência." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Fase lútea: a temperatura está acima do teu valor de referência." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Fase lútea: a temperatura está acima do teu valor de referência." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Fase lútea: a temperatura está acima do teu valor de referência." } } + } }, + "No clear temperature pattern yet. This can happen with irregular cycles, hormonal contraception or shift work.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Noch kein klares Temperaturmuster. Das kann bei unregelmäßigen Zyklen, hormoneller Verhütung oder Schichtarbeit vorkommen." } }, "en": { "stringUnit": { "state": "translated", "value": "No clear temperature pattern yet. This can happen with irregular cycles, hormonal contraception or shift work." } }, "es": { "stringUnit": { "state": "translated", "value": "Aún no hay un patrón de temperatura claro. Puede ocurrir con ciclos irregulares, anticoncepción hormonal o trabajo por turnos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun profil de température clair pour le moment. Cela peut arriver avec des cycles irréguliers, une contraception hormonale ou le travail posté." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda não existe um padrão de temperatura claro. Isto pode acontecer com ciclos irregulares, contraceção hormonal ou trabalho por turnos." } }, + "it": { "stringUnit": { "state": "translated", "value": "Ainda não existe um padrão de temperatura claro. Isto pode acontecer com ciclos irregulares, contraceção hormonal ou trabalho por turnos." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Ainda não existe um padrão de temperatura claro. Isto pode acontecer com ciclos irregulares, contraceção hormonal ou trabalho por turnos." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Ainda não existe um padrão de temperatura claro. Isto pode acontecer com ciclos irregulares, contraceção hormonal ou trabalho por turnos." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Ainda não existe um padrão de temperatura claro. Isto pode acontecer com ciclos irregulares, contraceção hormonal ou trabalho por turnos." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Ainda não existe um padrão de temperatura claro. Isto pode acontecer com ciclos irregulares, contraceção hormonal ou trabalho por turnos." } } + } }, + "Learning your pattern from your nightly temperature. Keep wearing your device overnight.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Muster wird anhand deiner nächtlichen Temperatur gelernt. Trage dein Gerät weiter über Nacht." } }, "en": { "stringUnit": { "state": "translated", "value": "Learning your pattern from your nightly temperature. Keep wearing your device overnight." } }, "es": { "stringUnit": { "state": "translated", "value": "Estamos aprendiendo tu patrón a partir de la temperatura nocturna. Sigue llevando el dispositivo por la noche." } }, "fr": { "stringUnit": { "state": "translated", "value": "Votre profil est appris à partir de votre température nocturne. Continuez à porter votre appareil la nuit." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Estamos a aprender o teu padrão a partir da temperatura noturna. Continua a usar o dispositivo durante a noite." } }, + "it": { "stringUnit": { "state": "translated", "value": "Estamos a aprender o teu padrão a partir da temperatura noturna. Continua a usar o dispositivo durante a noite." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Estamos a aprender o teu padrão a partir da temperatura noturna. Continua a usar o dispositivo durante a noite." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Estamos a aprender o teu padrão a partir da temperatura noturna. Continua a usar o dispositivo durante a noite." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Estamos a aprender o teu padrão a partir da temperatura noturna. Continua a usar o dispositivo durante a noite." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Estamos a aprender o teu padrão a partir da temperatura noturna. Continua a usar o dispositivo durante a noite." } } + } }, + "Your temperature shift came at a different time than your logged date – the logged start may be off.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Deine Temperaturveränderung trat zu einem anderen Zeitpunkt als das protokollierte Datum auf – der eingetragene Beginn könnte abweichen." } }, "en": { "stringUnit": { "state": "translated", "value": "Your temperature shift came at a different time than your logged date – the logged start may be off." } }, "es": { "stringUnit": { "state": "translated", "value": "El cambio de temperatura se produjo en otro momento distinto a la fecha registrada; puede que el inicio anotado no sea correcto." } }, "fr": { "stringUnit": { "state": "translated", "value": "Le changement de température est survenu à un autre moment que la date enregistrée ; le début indiqué est peut-être décalé." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A mudança de temperatura ocorreu num momento diferente da data registada; o início indicado pode estar desfasado." } }, + "it": { "stringUnit": { "state": "translated", "value": "A mudança de temperatura ocorreu num momento diferente da data registada; o início indicado pode estar desfasado." } }, + "pl": { "stringUnit": { "state": "translated", "value": "A mudança de temperatura ocorreu num momento diferente da data registada; o início indicado pode estar desfasado." } }, + "ru": { "stringUnit": { "state": "translated", "value": "A mudança de temperatura ocorreu num momento diferente da data registada; o início indicado pode estar desfasado." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "A mudança de temperatura ocorreu num momento diferente da data registada; o início indicado pode estar desfasado." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "A mudança de temperatura ocorreu num momento diferente da data registada; o início indicado pode estar desfasado." } } + } }, + "Your rhythm is hard to read right now. Keep wearing your device for a clearer picture.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Dein Rhythmus ist gerade schwer zu erkennen. Trage dein Gerät weiter, damit das Bild klarer wird." } }, "en": { "stringUnit": { "state": "translated", "value": "Your rhythm is hard to read right now. Keep wearing your device for a clearer picture." } }, "es": { "stringUnit": { "state": "translated", "value": "Ahora mismo es difícil interpretar tu ritmo. Sigue llevando el dispositivo para obtener una imagen más clara." } }, "fr": { "stringUnit": { "state": "translated", "value": "Votre rythme est difficile à interpréter pour le moment. Continuez à porter votre appareil pour obtenir une image plus claire." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Neste momento, é difícil interpretar o teu ritmo. Continua a usar o dispositivo para obter uma imagem mais clara." } }, + "it": { "stringUnit": { "state": "translated", "value": "Neste momento, é difícil interpretar o teu ritmo. Continua a usar o dispositivo para obter uma imagem mais clara." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Neste momento, é difícil interpretar o teu ritmo. Continua a usar o dispositivo para obter uma imagem mais clara." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Neste momento, é difícil interpretar o teu ritmo. Continua a usar o dispositivo para obter uma imagem mais clara." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Neste momento, é difícil interpretar o teu ritmo. Continua a usar o dispositivo para obter uma imagem mais clara." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Neste momento, é difícil interpretar o teu ritmo. Continua a usar o dispositivo para obter uma imagem mais clara." } } + } }, + "Your body clock looks later, with a night-owl tendency.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Deine innere Uhr scheint später zu laufen – mit einer Tendenz zum Nachtmenschen." } }, "en": { "stringUnit": { "state": "translated", "value": "Your body clock looks later, with a night-owl tendency." } }, "es": { "stringUnit": { "state": "translated", "value": "Tu reloj biológico parece más tardío, con tendencia nocturna." } }, "fr": { "stringUnit": { "state": "translated", "value": "Votre horloge biologique semble plus tardive, avec une tendance couche-tard." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais tardio, com tendência noturna." } }, + "it": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais tardio, com tendência noturna." } }, + "pl": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais tardio, com tendência noturna." } }, + "ru": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais tardio, com tendência noturna." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais tardio, com tendência noturna." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais tardio, com tendência noturna." } } + } }, + "Your body clock looks earlier, with a morning tendency.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Deine innere Uhr scheint früher zu laufen – mit einer Tendenz zum Morgenmenschen." } }, "en": { "stringUnit": { "state": "translated", "value": "Your body clock looks earlier, with a morning tendency." } }, "es": { "stringUnit": { "state": "translated", "value": "Tu reloj biológico parece más temprano, con tendencia matutina." } }, "fr": { "stringUnit": { "state": "translated", "value": "Votre horloge biologique semble plus précoce, avec une tendance matinale." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais adiantado, com tendência matinal." } }, + "it": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais adiantado, com tendência matinal." } }, + "pl": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais adiantado, com tendência matinal." } }, + "ru": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais adiantado, com tendência matinal." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais adiantado, com tendência matinal." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece mais adiantado, com tendência matinal." } } + } }, + "Your body clock looks aligned with your schedule.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Deine innere Uhr scheint zu deinem Zeitplan zu passen." } }, "en": { "stringUnit": { "state": "translated", "value": "Your body clock looks aligned with your schedule." } }, "es": { "stringUnit": { "state": "translated", "value": "Tu reloj biológico parece estar alineado con tu horario." } }, "fr": { "stringUnit": { "state": "translated", "value": "Votre horloge biologique semble alignée sur votre emploi du temps." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece alinhado com o teu horário." } }, + "it": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece alinhado com o teu horário." } }, + "pl": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece alinhado com o teu horário." } }, + "ru": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece alinhado com o teu horário." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece alinhado com o teu horário." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "O teu relógio biológico parece alinhado com o teu horário." } } + } }, + "Shifting your clock %@ h earlier, about an hour a day. Light and sleep timing only.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Deine innere Uhr wird um %@ Std. nach vorn verschoben, etwa eine Stunde pro Tag. Nur Licht- und Schlafzeiten." } }, "en": { "stringUnit": { "state": "translated", "value": "Shifting your clock %@ h earlier, about an hour a day. Light and sleep timing only." } }, "es": { "stringUnit": { "state": "translated", "value": "Adelanta tu reloj %@ h, aproximadamente una hora al día. Solo se ajustan la luz y los horarios de sueño." } }, "fr": { "stringUnit": { "state": "translated", "value": "Avance de votre horloge de %@ h, environ une heure par jour. Uniquement la lumière et les horaires de sommeil." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Adianta o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "it": { "stringUnit": { "state": "translated", "value": "Adianta o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Adianta o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Adianta o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Adianta o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Adianta o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } } + } }, + "Shifting your clock %@ h later, about an hour a day. Light and sleep timing only.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Deine innere Uhr wird um %@ Std. nach hinten verschoben, etwa eine Stunde pro Tag. Nur Licht- und Schlafzeiten." } }, "en": { "stringUnit": { "state": "translated", "value": "Shifting your clock %@ h later, about an hour a day. Light and sleep timing only." } }, "es": { "stringUnit": { "state": "translated", "value": "Retrasa tu reloj %@ h, aproximadamente una hora al día. Solo se ajustan la luz y los horarios de sueño." } }, "fr": { "stringUnit": { "state": "translated", "value": "Retarde votre horloge de %@ h, environ une heure par jour. Uniquement la lumière et les horaires de sommeil." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Atrasa o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "it": { "stringUnit": { "state": "translated", "value": "Atrasa o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Atrasa o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Atrasa o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Atrasa o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Atrasa o teu relógio %@ h, cerca de uma hora por dia. Apenas luz e horários de sono." } } + } }, + "No meaningful body-clock shift is needed. You are about aligned.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Keine nennenswerte Verschiebung deiner inneren Uhr nötig. Du bist weitgehend im Rhythmus." } }, "en": { "stringUnit": { "state": "translated", "value": "No meaningful body-clock shift is needed. You are about aligned." } }, "es": { "stringUnit": { "state": "translated", "value": "No necesitas ajustar de forma significativa tu reloj biológico. Está prácticamente alineado." } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun décalage notable de votre horloge biologique n’est nécessaire. Vous êtes globalement aligné." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Não é necessário um ajuste significativo do teu relógio biológico. Está praticamente alinhado." } }, + "it": { "stringUnit": { "state": "translated", "value": "Não é necessário um ajuste significativo do teu relógio biológico. Está praticamente alinhado." } }, + "pl": { "stringUnit": { "state": "translated", "value": "Não é necessário um ajuste significativo do teu relógio biológico. Está praticamente alinhado." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Não é necessário um ajuste significativo do teu relógio biológico. Está praticamente alinhado." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Não é necessário um ajuste significativo do teu relógio biológico. Está praticamente alinhado." } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Não é necessário um ajuste significativo do teu relógio biológico. Está praticamente alinhado." } } + } }, + "Cached": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Zwischengespeichert" } }, + "en": { "stringUnit": { "state": "translated", "value": "Cached" } }, + "es": { "stringUnit": { "state": "translated", "value": "En caché" } }, + "fr": { "stringUnit": { "state": "translated", "value": "En cache" } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "Em cache" } }, + "it": { "stringUnit": { "state": "translated", "value": "Em cache" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Em cache" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Em cache" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Em cache" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Em cache" } } + } + }, + "Swipe": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wischen" } }, + "en": { "stringUnit": { "state": "translated", "value": "Swipe" } }, + "es": { "stringUnit": { "state": "translated", "value": "Desliza" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Balayer" } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "Deslizar" } }, + "it": { "stringUnit": { "state": "translated", "value": "Deslizar" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Deslizar" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Deslizar" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Deslizar" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Deslizar" } } + } + }, + "Tap": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Tippen" } }, + "en": { "stringUnit": { "state": "translated", "value": "Tap" } }, + "es": { "stringUnit": { "state": "translated", "value": "Toca" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Toucher" } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "Tocar" } }, + "it": { "stringUnit": { "state": "translated", "value": "Tocar" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Tocar" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Tocar" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Tocar" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Tocar" } } + } + }, + "Workout files": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Trainingsdateien" } }, + "en": { "stringUnit": { "state": "translated", "value": "Workout files" } }, + "es": { "stringUnit": { "state": "translated", "value": "Archivos de entrenamiento" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Fichiers d’entraînement" } }, + "pt-PT": { "stringUnit": { "state": "translated", "value": "Ficheiros de treino" } }, + "it": { "stringUnit": { "state": "translated", "value": "Ficheiros de treino" } }, + "pl": { "stringUnit": { "state": "translated", "value": "Ficheiros de treino" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Ficheiros de treino" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Ficheiros de treino" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "Ficheiros de treino" } } + } + }, "Auto-backup hasn't run in a few days. Check the backup folder is still available — a moved or disconnected cloud folder stops backups silently.": { "localizations": { "de": { @@ -8146,34 +8419,40 @@ "%lld days": { "localizations": { "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Tage" - } + "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld Tag" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld Tage" } } + } } + }, + "en": { + "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld day" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld days" } } + } } }, "es": { - "stringUnit": { - "state": "translated", - "value": "%lld días" - } + "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld día" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld días" } } + } } }, "fr": { - "stringUnit": { - "state": "translated", - "value": "%lld jours" - } + "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld jour" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld jours" } } + } } }, "it": { - "stringUnit": { - "state": "translated", - "value": "%lld giorni" - } + "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld giorno" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld giorni" } } + } } }, "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%lld dias" - } + "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld dia" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld dias" } } + } } }, "ru": { "variations": { @@ -8562,7 +8841,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "%1$lld dias · %2$lld dorme" + "value": "%1$lld dias · %2$lld noites de sono" } }, "ru": { @@ -12514,38 +12793,100 @@ } } }, + "%lld sleeps": { + "localizations": { + "de": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld Nacht" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld Nächte" } } + } } }, + "en": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld sleep" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld sleeps" } } + } } }, + "es": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld noche" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld noches" } } + } } }, + "fr": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld nuit" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld nuits" } } + } } }, + "it": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld notte" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld notti" } } + } } }, + "pl": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld noc" } }, + "few": { "stringUnit": { "state": "translated", "value": "%lld noce" } }, + "many": { "stringUnit": { "state": "translated", "value": "%lld nocy" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld nocy" } } + } } }, + "pt-PT": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld noite de sono" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld noites de sono" } } + } } }, + "ru": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld ночь" } }, + "few": { "stringUnit": { "state": "translated", "value": "%lld ночи" } }, + "many": { "stringUnit": { "state": "translated", "value": "%lld ночей" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld ночи" } } + } } }, + "zh-Hans": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld 次睡眠" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld 次睡眠" } } + } } }, + "zh-Hant": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld 次睡眠" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld 次睡眠" } } + } } } + } + }, "%lld workouts": { "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Workouts" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld entrenamientos" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%lld entraînements" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "%lld treinos" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Treningi %lld" - } - } + "de": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld Workout" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld Workouts" } } + } } }, + "en": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld workout" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld workouts" } } + } } }, + "es": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld entrenamiento" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld entrenamientos" } } + } } }, + "fr": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld entraînement" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld entraînements" } } + } } }, + "it": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld allenamento" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld allenamenti" } } + } } }, + "pl": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld trening" } }, + "few": { "stringUnit": { "state": "translated", "value": "%lld treningi" } }, + "many": { "stringUnit": { "state": "translated", "value": "%lld treningów" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld treningu" } } + } } }, + "pt-PT": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld treino" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld treinos" } } + } } }, + "ru": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld тренировка" } }, + "few": { "stringUnit": { "state": "translated", "value": "%lld тренировки" } }, + "many": { "stringUnit": { "state": "translated", "value": "%lld тренировок" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld тренировки" } } + } } }, + "zh-Hans": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld 次训练" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld 次训练" } } + } } }, + "zh-Hant": { "variations": { "plural": { + "one": { "stringUnit": { "state": "translated", "value": "%lld 次訓練" } }, + "other": { "stringUnit": { "state": "translated", "value": "%lld 次訓練" } } + } } } } }, "%lld year%@ %@ than your age": { @@ -103097,7 +103438,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Precisa da pega" + "value": "Requer a pulseira" } }, "ru": { @@ -121923,7 +122264,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Pressionar" + "value": "Avançar" } }, "ru": { @@ -131792,7 +132133,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Descanse RH" + "value": "FC repouso" } }, "ru": { @@ -132416,7 +132757,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Descansando RH" + "value": "FC em repouso" } }, "ru": { @@ -134846,7 +135187,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Atropelar" + "value": "Esgotado" } }, "ru": { @@ -136360,7 +136701,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "SYNTHESIS" + "value": "SÍNTESE" } }, "ru": { @@ -148966,7 +149307,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Continuar" + "value": "Parado" } }, "ru": { @@ -150184,7 +150525,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Bateria de pega" + "value": "Bateria da pulseira" } }, "ru": { @@ -174327,7 +174668,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "Grito" + "value": "WHOOP" } }, "ru": { @@ -178552,7 +178893,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "As tuas cartas" + "value": "Os teus cartões" } }, "ru": { @@ -193261,7 +193602,7 @@ "pt-PT": { "stringUnit": { "state": "translated", - "value": "~%lldh esquerda" + "value": "Faltam ~%lld h" } }, "ru": { diff --git a/Strand/Screens/HealthAlertBanner.swift b/Strand/Screens/HealthAlertBanner.swift index c360fe1633..52b26df058 100644 --- a/Strand/Screens/HealthAlertBanner.swift +++ b/Strand/Screens/HealthAlertBanner.swift @@ -1,5 +1,7 @@ import SwiftUI import StrandDesign +import StrandAnalytics +import Foundation /// Strain/illness early-warning banner. Observes AppModel in isolation so the ~1 Hz HR stream /// re-renders only this small view, not the whole screen. Renders nothing when there's no alert. @@ -7,6 +9,7 @@ struct HealthAlertBanner: View { @EnvironmentObject var model: AppModel var body: some View { if let alert = model.healthAlert { + let copy = localizedHealthAlertCopy(alert) // A frosted, warning-tinted alert card (not a flat coloured bar) — prominent but on-brand. // The amber wash + a glyph in a soft amber chip read as an early-warning without a hard rule. NoopCard(padding: 14, tint: StrandPalette.statusWarning) { @@ -17,7 +20,7 @@ struct HealthAlertBanner: View { .frame(width: 30, height: 30) .background(StrandPalette.statusWarning.opacity(0.16), in: Circle()) .accessibilityHidden(true) - Text(alert) + Text(copy) .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textPrimary) .fixedSize(horizontal: false, vertical: true) @@ -26,6 +29,27 @@ struct HealthAlertBanner: View { .frame(maxWidth: .infinity, alignment: .leading) } .accessibilityElement(children: .combine) + .accessibilityLabel(copy) } } } + +/// Home-facing rendering of the semantic illness result. Both Today variants share this banner, +/// so neither can accidentally expose the analytics engine's English notification copy. +func localizedHealthAlertCopy(_ alert: AppModel.HealthAlert) -> String { + if alert.message == .raised { + let formatter = ListFormatter() + formatter.locale = AppLanguage.activeLocale + let signals = formatter.string(from: alert.firedSignals) ?? alert.firedSignals.joined(separator: ", ") + return String(localized: "Your body looks strained. Signals up: \(signals). No alcohol or travel was logged, so consider taking it easy. On-device estimate, not a diagnosis.") + } + if alert.message == .alreadyUnwellAgree { + return String(localized: "You logged feeling unwell, and your signals agree. Take it easy today. On-device estimate, not a diagnosis.") + } + if alert.message == .alreadyUnwell { + return String(localized: "You logged feeling unwell. Take it easy today. On-device estimate, not a diagnosis.") + } + // The publisher gates the banner to raised/already-unwell. Keep the impossible fallback localized + // and semantic rather than leaking `Result.copy` if a future caller bypasses that gate. + return String(localized: "Nothing notable. Your signals look like their normal range.") +} diff --git a/Strand/Screens/SkinTempCardsView.swift b/Strand/Screens/SkinTempCardsView.swift index 2eaedf4b62..4fbfaf1819 100644 --- a/Strand/Screens/SkinTempCardsView.swift +++ b/Strand/Screens/SkinTempCardsView.swift @@ -108,7 +108,7 @@ struct CycleAwarenessCard: View { Divider().overlay(StrandPalette.hairline) // The standing awareness-only legal line (verbatim from the engine) + privacy promise. - Text(CyclePhaseEngine.awarenessLine) + Text(String(localized: "For awareness only. Not a medical device, not contraception, not a substitute for professional care.")) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -208,7 +208,7 @@ struct CycleAwarenessCard: View { return lo == hi ? String(localized: "· ~day \(lo)") : String(localized: "· ~day \(lo)–\(hi)") } - private var statusLine: String { result.note } + private var statusLine: String { localizedCycleStatus(result) } private var confidenceLabel: LocalizedStringKey { switch result.confidence { @@ -351,13 +351,17 @@ struct MenstrualCycleHomeCard: View { } HStack(spacing: NoopMetrics.space2) { - Button(cycleEnabled ? "Open tracker" : "Set up cycle tracking") { + Button(cycleEnabled + ? String(localized: "Open tracker") + : String(localized: "Set up cycle tracking")) { openTracker() } .buttonStyle(.noopSecondary) if cycleEnabled { - Button(starts.contains(Repository.localDayKey(Date())) ? "Logged today" : "Log today") { + Button(starts.contains(Repository.localDayKey(Date())) + ? String(localized: "Logged today") + : String(localized: "Log today")) { logToday() } .buttonStyle(.noopGhost) @@ -455,7 +459,7 @@ struct CycleTrackerView: View { logCard historyCard VStack(alignment: .leading, spacing: NoopMetrics.gap) { - Text(CyclePhaseEngine.awarenessLine) + Text(String(localized: "For awareness only. Not a medical device, not contraception, not a substitute for professional care.")) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) PrivacyNote() @@ -516,7 +520,7 @@ struct CycleTrackerView: View { .frame(height: NoopMetrics.space10) .accessibilityHidden(true) } - Text(result.note) + Text(localizedCycleStatus(result)) .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) if let window = result.nextPeriodWindow { @@ -535,7 +539,9 @@ struct CycleTrackerView: View { DatePicker("Period started on", selection: $selectedDate, in: ...Date(), displayedComponents: .date) .font(StrandFont.body) - Button(alreadyLogged ? "Already logged" : "Log period start") { + Button(alreadyLogged + ? String(localized: "Already logged") + : String(localized: "Log period start")) { Task { await repo.logPeriodStart(day: selectedDay) await model.refreshV5Signals() @@ -628,7 +634,7 @@ struct BodyClockCard: View { offsetHeadline - Text(estimate.note) + Text(localizedBodyClockNote(estimate)) .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -651,7 +657,9 @@ struct BodyClockCard: View { } if let onOpenPlanner { - Button(plan == nil ? "Plan a trip or shift" : "View the full plan", action: onOpenPlanner) + Button(plan == nil + ? String(localized: "Plan a trip or shift") + : String(localized: "View the full plan"), action: onOpenPlanner) .buttonStyle(.noopGhost) .padding(.top, 2) } @@ -692,7 +700,7 @@ struct BodyClockCard: View { .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) - Text(plan.note) + Text(localizedPlanNote(plan)) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -732,14 +740,26 @@ struct BodyClockCard: View { } } - /// Render a fractional clock hour as "HH:MM" (locale-free, matching the engine's own helper). + /// Render a fractional clock hour using the app-selected locale's 12/24-hour convention. private func clockString(_ hour: Double) -> String { var h = hour.truncatingRemainder(dividingBy: 24) if h < 0 { h += 24 } var hh = Int(h) var mm = Int(((h - Double(hh)) * 60).rounded()) if mm == 60 { mm = 0; hh = (hh + 1) % 24 } - return String(format: "%02d:%02d", hh, mm) + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.year = 2001 + components.month = 1 + components.day = 1 + components.hour = hh + components.minute = mm + guard let date = components.date else { return "\(hh):\(String(format: "%02d", mm))" } + let formatter = DateFormatter() + formatter.locale = AppLanguage.activeLocale + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter.string(from: date) } } @@ -770,7 +790,7 @@ struct HeadsUpCard: View { Text(title) .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) - Text(result.copy) + Text(localizedIllnessCopy(result)) .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -784,7 +804,11 @@ struct HeadsUpCard: View { } // ...and what was ruled out (the differentiating part vs a black-box warning). if !result.suppressedBy.isEmpty { - whyRow(label: "Explained by", values: result.suppressedBy, tint: StrandPalette.textTertiary) + let reasons = result.suppressionReasons.isEmpty + ? result.suppressedBy.map(localizedConfounder) + : result.suppressionReasons.map(localizedConfounder) + whyRow(label: "Explained by", values: reasons, + tint: StrandPalette.textTertiary) } // Optional confidence read from the parallel Mahalanobis distance, only when the level is // raised. Subtle by design: it augments, never gates (the engine already decided to raise). @@ -797,7 +821,7 @@ struct HeadsUpCard: View { .frame(maxWidth: .infinity, alignment: .leading) } .accessibilityElement(children: .combine) - .accessibilityLabel("\(title). \(result.copy)") + .accessibilityLabel("\(title). \(localizedIllnessCopy(result))") } private func whyRow(label: LocalizedStringKey, values: [String], tint: Color) -> some View { @@ -866,7 +890,7 @@ enum IllnessConfidence { return String(localized: "slight") } static func formatted(_ distance: Double) -> String { - String(format: "%.1f", distance) + String(format: "%.1f", locale: AppLanguage.activeLocale, distance) } } @@ -900,13 +924,118 @@ private struct FlowChips: View { /// "12 Jun" from a "yyyy-MM-dd" key (locale-aware for display only; the engine math stays UTC). private func prettyDay(_ key: String) -> String { - let parts = key.split(separator: "-") - guard parts.count == 3, let m = Int(parts[1]), let d = Int(parts[2]), (1...12).contains(m) else { return key } - let months = [String(localized: "Jan"), String(localized: "Feb"), String(localized: "Mar"), - String(localized: "Apr"), String(localized: "May"), String(localized: "Jun"), - String(localized: "Jul"), String(localized: "Aug"), String(localized: "Sep"), - String(localized: "Oct"), String(localized: "Nov"), String(localized: "Dec")] - return "\(d) \(months[m - 1])" + let parser = DateFormatter() + parser.calendar = Calendar(identifier: .gregorian) + parser.locale = Locale(identifier: "en_US_POSIX") + parser.dateFormat = "yyyy-MM-dd" + guard let date = parser.date(from: key) else { return key } + return date.formatted( + .dateTime.day().month(.abbreviated).locale(AppLanguage.activeLocale) + ) +} + +/// The analytics engines stay locale-free for Swift/Kotlin parity. Translate their finite presentation +/// states at the UI boundary so none of that engine copy leaks onto Home in English. +private func localizedCycleStatus(_ result: CyclePhaseEngine.Result) -> String { + if result.note.contains("different time") { + return String(localized: "Your temperature shift came at a different time than your logged date – the logged start may be off.") + } + switch result.phase { + case .follicular: + return String(localized: "Follicular range – temperature is near your baseline.") + case .periOvulatory: + return String(localized: "Around your mid-cycle shift – temperature is changing.") + case .luteal: + return String(localized: "Luteal range – temperature is above your baseline.") + case .unknown: + return String(localized: "No clear temperature pattern yet. This can happen with irregular cycles, hormonal contraception or shift work.") + case .learning: + return String(localized: "Learning your pattern from your nightly temperature. Keep wearing your device overnight.") + } +} + +private func localizedBodyClockNote(_ estimate: CircadianEngine.PhaseEstimate) -> String { + if estimate.confidence == .unreadable { + return String(localized: "Your rhythm is hard to read right now. Keep wearing your device for a clearer picture.") + } + if estimate.offsetVsScheduleMinutes > 20 { + return String(localized: "Your body clock looks later, with a night-owl tendency.") + } + if estimate.offsetVsScheduleMinutes < -20 { + return String(localized: "Your body clock looks earlier, with a morning tendency.") + } + return String(localized: "Your body clock looks aligned with your schedule.") +} + +private func localizedPlanNote(_ plan: CircadianEngine.JetLagPlan) -> String { + let hours = String(format: "%.1f", locale: AppLanguage.activeLocale, plan.totalShiftHours) + switch plan.direction { + case .advance: + return String(localized: "Shifting your clock \(hours) h earlier, about an hour a day. Light and sleep timing only.") + case .delay: + return String(localized: "Shifting your clock \(hours) h later, about an hour a day. Light and sleep timing only.") + case .none: + return String(localized: "No meaningful body-clock shift is needed. You are about aligned.") + } +} + +private func localizedIllnessCopy(_ result: IllnessSignalEngine.Result) -> String { + let signals = localizedList(result.firedSignals) + let reasons = localizedList(result.suppressedBy.map(localizedConfounder)) + let message = result.message ?? fallbackIllnessMessage(result.level) + switch message { + case .raised: + return String(localized: "Your body looks strained. Signals up: \(signals). No alcohol or travel was logged, so consider taking it easy. On-device estimate, not a diagnosis.") + case .alreadyUnwellAgree: + return String(localized: "You logged feeling unwell, and your signals agree. Take it easy today. On-device estimate, not a diagnosis.") + case .alreadyUnwell: + return String(localized: "You logged feeling unwell. Take it easy today. On-device estimate, not a diagnosis.") + case .suppressed: + return String(localized: "Some signals are up, but you logged \(reasons). That is the more likely explanation. On-device estimate, not a diagnosis.") + case .mild: + return String(localized: "A few signals are mildly up: \(signals). Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis.") + case .learningBaseline: + return String(localized: "Still learning your baseline and keeping an eye on your signals.") + case .normal: + return String(localized: "Nothing notable. Your signals look like their normal range.") + } +} + +private func fallbackIllnessMessage(_ level: IllnessSignalEngine.Level) -> IllnessSignalEngine.Message { + switch level { + case .raised: return .raised + case .alreadyUnwell: return .alreadyUnwell + case .suppressed: return .suppressed + case .mild: return .mild + case .quiet: return .normal + } +} + +private func localizedConfounder(_ value: String) -> String { + switch value { + case "alcohol": return String(localized: "Alcohol").lowercased(with: AppLanguage.activeLocale) + case "stress": return String(localized: "Stress").lowercased(with: AppLanguage.activeLocale) + case "sauna": return String(localized: "Sauna") + case "hardOrLateWorkout", "a hard or late workout": return String(localized: "Hard or late workout") + case "travel": return String(localized: "Travel") + default: return value + } +} + +private func localizedConfounder(_ reason: IllnessSignalEngine.SuppressionReason) -> String { + switch reason { + case .alcohol: return String(localized: "Alcohol").lowercased(with: AppLanguage.activeLocale) + case .stress: return String(localized: "Stress").lowercased(with: AppLanguage.activeLocale) + case .sauna: return String(localized: "Sauna") + case .hardOrLateWorkout: return String(localized: "Hard or late workout") + case .travel: return String(localized: "Travel") + } +} + +private func localizedList(_ values: [String]) -> String { + let formatter = ListFormatter() + formatter.locale = AppLanguage.activeLocale + return formatter.string(from: values) ?? values.joined(separator: ", ") } #if DEBUG diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 6242e78eb2..a59370d236 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -169,6 +169,9 @@ struct ActiveWorkoutIndicatorSection: View { } struct TodayView: View { + /// Product mark, never natural-language copy. Keeping it out of localization also makes source + /// classification and tint selection stable when the app language changes. + private static let whoopBrandName = "WHOOP" @EnvironmentObject var repo: Repository // PERF (scroll stutter): TodayView deliberately does NOT observe `LiveState` directly. A connected // strap publishes `LiveState` ~1 Hz (heart rate + each R-R packet), and an `@EnvironmentObject live` @@ -803,21 +806,35 @@ struct TodayView: View { /// Any other real source (Mi Band, Health Connect, nutrition) keeps its `FusionSource.displayName` ///, still the genuine merge winner, never a blanket claim. Mirror EXACTLY in Kotlin. static func provenanceDisplayLabel(rawSource: String, deviceId: String) -> String { - if rawSource.hasSuffix("-noop") { return "On-device" } - if rawSource == deviceId || rawSource == Repository.whoopSource { return "Whoop" } + if rawSource.hasSuffix("-noop") { return String(localized: "On-device") } + if rawSource == deviceId || rawSource == Repository.whoopSource { return Self.whoopBrandName } if rawSource == Repository.appleHealthSource { return "Apple Health" } - // Fall back to the FusionSource display name for any other known source; else the raw id. - return FusionSource(rawValue: rawSource)?.displayName ?? rawSource + // Localize the non-brand source names here rather than exposing the analytics layer's + // intentionally locale-free wire/display vocabulary on Home. + switch FusionSource(rawValue: rawSource) { + case .healthConnect: return "Health Connect" + case .xiaomiBand: return "Mi Band" + case .nutritionCsv: return String(localized: "Nutrition") + case .localCache: return String(localized: "Cached") + case .whoopImport: return Self.whoopBrandName + case .noopComputed: return String(localized: "On-device") + case .appleHealth: return "Apple Health" + case nil: return rawSource + } } /// The tint for a provenance badge, gold for Whoop, cyan for Apple Health, the positive status hue /// for on-device, matching the Data Sources footer so the same source reads the same colour on Today. private func provenanceTint(_ metricKey: String) -> Color { - switch provenanceLabel(metricKey) { - case "Whoop": return StrandPalette.accent - case "Apple Health": return StrandPalette.metricCyan - default: return StrandPalette.statusPositive + guard let provider = providerByMetric[metricKey] else { return StrandPalette.statusPositive } + let source = provider.sourceId.lowercased() + if source.hasSuffix("-noop") { return StrandPalette.statusPositive } + if source == Repository.appleHealthSource { return StrandPalette.metricCyan } + if source == Repository.whoopSource + || provider.brand?.caseInsensitiveCompare("WHOOP") == .orderedSame { + return StrandPalette.accent } + return StrandPalette.statusPositive } // MARK: Apple Watch provenance (M1): "the watch is the sensor, NOOP is the brain" @@ -858,15 +875,20 @@ struct TodayView: View { case "fitbit-import": return "Fitbit" case "garmin-import": return "Garmin" case "xiaomi-band": return "Mi Band" - case Repository.activityFileSource: return "Workout files" + case Repository.activityFileSource: return String(localized: "Workout files") default: break } if let brand = brand?.trimmingCharacters(in: .whitespacesAndNewlines), !brand.isEmpty { - return brand.caseInsensitiveCompare("WHOOP") == .orderedSame ? "Whoop" : brand + return brand.caseInsensitiveCompare("WHOOP") == .orderedSame ? Self.whoopBrandName : brand + } + if source == Repository.whoopSource { return Self.whoopBrandName } + switch FusionSource(rawValue: sourceId) { + case .nutritionCsv: return String(localized: "Nutrition") + case .localCache: return String(localized: "Cached") + case let known?: return known.displayName + case nil: return sourceId } - if source == Repository.whoopSource { return "Whoop" } - return FusionSource(rawValue: sourceId)?.displayName ?? sourceId } /// True for a watch-context user with no strap supplying scores (Apple-Health days present and no WHOOP @@ -1042,14 +1064,13 @@ struct TodayView: View { // Anchor to the LOGICAL day, not raw Date(), so the a11y date label agrees with the visible // date and the picker highlight in the 00:00-04:00 window (a raw Date() reads a calendar day // ahead there, mismatching at offset >= 2) (#16). - return Self.navDayFmt.string(from: selectedLogicalDay) + return selectedLogicalDay.formatted( + .dateTime.weekday(.abbreviated).day().month(.abbreviated) + .locale(AppLanguage.activeLocale) + ) } } - private static let navDayFmt: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "EEE d MMM"; f.locale = Locale(identifier: "en_US_POSIX"); return f - }() - /// The selected day as a small locale-aware numeric date ("28/06/2026" or "6/28/2026" per region). The /// top bar shows just this now, no "Today" / "Yesterday" word and no prev/next arrows. Day-change is by /// horizontal swipe or by tapping to open the picker, and the rotating hint below teaches both. @@ -1068,7 +1089,9 @@ struct TodayView: View { /// Periodic one-word hint shown in place of the date for ~1.5s every ~10s (nil = show the date). With the /// arrows gone the day-nav affordances are otherwise invisible, so this teaches them in the accent colour. @State private var dayNavHint: String? = nil - private static let dayNavHints = ["Swipe", "Tap"] + private static var dayNavHints: [String] { + [String(localized: "Swipe"), String(localized: "Tap")] + } #endif /// #829 follow-up: the named coordinate space the day-swipe drag and the HR-chart frame reader share, @@ -1593,6 +1616,8 @@ struct TodayView: View { /// glanceable verdict. Hidden when there isn't enough history (the `.insufficient` level). @ViewBuilder private func readinessCard(_ r: ReadinessEngine.Readiness) -> some View { + let headline = readinessHeadlineText(r.level) + let summary = readinessSummaryText(r.level) VStack(alignment: .leading, spacing: NoopMetrics.gap) { // When Readiness is anchored on the carried last-scored day (#543), the overline stamps its // date so the prior read isn't passed off as today's; otherwise the usual prompt. @@ -1603,15 +1628,15 @@ struct TodayView: View { HStack(spacing: 10) { Circle().fill(readinessColor(r.level)).frame(width: 10, height: 10) .accessibilityHidden(true) - Text(LocalizedStringKey(r.headline)).font(StrandFont.headline) + Text(headline).font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) - .accessibilityLabel("Readiness: \(levelWord(r.level)). \(r.headline)") + .accessibilityLabel("Readiness: \(levelWord(r.level)). \(headline)") Spacer() if let acwr = r.acwr { - Text("load \(String(format: "%.2f", acwr))") + Text("load \(String(format: "%.2f", locale: AppLanguage.activeLocale, acwr))") .font(StrandFont.captionNumber) .foregroundStyle(StrandPalette.textTertiary) - .help("Acute (7-day) vs chronic (28-day) training load. 0.8-1.3 is the sweet spot.") + .help("Acute (7-day) vs chronic (28-day) training load. 0.8–1.3 is the sweet spot.") } } // #1405: mark this as a DIFFERENT axis from the home Synthesis word (the Charge-% @@ -1621,12 +1646,14 @@ struct TodayView: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) - Text(LocalizedStringKey(r.summary)).font(StrandFont.subhead) + Text(summary).font(StrandFont.subhead) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) if !r.signals.isEmpty { Divider().overlay(StrandPalette.hairline) ForEach(r.signals, id: \.key) { s in + let label = readinessSignalLabel(s.key) + let detail = readinessDetailText(s) HStack(alignment: .top, spacing: 8) { // Glyph + colour (not colour alone) so the flag reads // for colour-blind users; hidden from VoiceOver since the @@ -1637,9 +1664,9 @@ struct TodayView: View { .padding(.top, 4) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(LocalizedStringKey(s.label)).font(StrandFont.caption) + Text(label).font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) - if let evidence = s.evidence { + if let evidence = readinessEvidenceText(s.evidenceData) { Text(evidence).font(StrandFont.captionNumber) .foregroundStyle(StrandPalette.textTertiary) .lineLimit(1) @@ -1647,13 +1674,13 @@ struct TodayView: View { } } .frame(width: 104, alignment: .leading) - Text(LocalizedStringKey(s.detail)).font(StrandFont.caption) + Text(detail).font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .accessibilityElement(children: .ignore) - .accessibilityLabel(Text("\(Text(LocalizedStringKey(s.label))), \(flagWord(s.flag)): \(Text(LocalizedStringKey(s.detail)))")) + .accessibilityLabel("\(label), \(flagWord(s.flag)): \(detail)") } } } @@ -1664,6 +1691,92 @@ struct TodayView: View { // Word + glyph equivalents so the colour-coded severity isn't carried by hue // alone, read by VoiceOver and visible to colour-blind users. + private func readinessHeadlineText(_ level: ReadinessEngine.Level) -> String { + switch level { + case .primed: return String(localized: "Primed") + case .balanced: return String(localized: "Balanced") + case .strained: return String(localized: "Strained") + case .rundown: return String(localized: "Run down") + case .insufficient: return String(localized: "Readiness") + } + } + + private func readinessSummaryText(_ level: ReadinessEngine.Level) -> String { + switch level { + case .primed: + return String(localized: "Your signals are aligned and your load is supported. A harder session is well backed today.") + case .balanced: + return String(localized: "Nothing's flagging. Train to feel - your body's holding steady.") + case .strained: + return String(localized: "One of your signals is flagging. You can train, but keep it controlled and bank the recovery.") + case .rundown: + return String(localized: "Several signals are down at once. Treat today as recovery - easy movement, real sleep tonight.") + case .insufficient: + return String(localized: "A few more nights of data and your readiness read will sharpen.") + } + } + + private func readinessSignalLabel(_ key: String) -> String { + switch key { + case "hrv": return String(localized: "HRV") + case "rhr": return String(localized: "Resting HR") + case "respRate": return String(localized: "Respiratory rate") + case "acwr": return String(localized: "Training load") + case "monotony": return String(localized: "Training variety") + default: return key + } + } + + private func readinessEvidenceText(_ evidence: ReadinessEngine.Evidence?) -> String? { + guard let evidence else { return nil } + switch evidence { + case .metric(let value, let baseline, let unit, let decimals): + let valueText = readinessNumber(value, decimals: decimals) + let baselineText = readinessNumber(baseline, decimals: decimals) + return String(localized: "\(valueText) vs \(baselineText) \(unit)") + case .trainingLoad(let acute, let chronic): + let acuteText = readinessNumber(acute, decimals: 1) + let chronicText = readinessNumber(chronic, decimals: 1) + return String(localized: "7d \(acuteText) / 28d \(chronicText)") + case .monotony(let value): + return String(localized: "monotony \(readinessNumber(value, decimals: 1))") + } + } + + private func readinessDetailText(_ signal: ReadinessEngine.Signal) -> String { + if signal.key == "acwr", let evidence = signal.evidenceData, + case .trainingLoad(let acute, let chronic) = evidence { + let ratio = readinessNumber(chronic > 0 ? acute / chronic : 0, decimals: 2) + switch signal.flag { + case .good: return String(localized: "in the sweet spot (acute:chronic \(ratio))") + case .bad: return String(localized: "spiking (acute:chronic \(ratio)) - higher injury risk") + case .watch: return acute < chronic + ? String(localized: "ramping down (acute:chronic \(ratio)) - room to build") + : String(localized: "building fast (acute:chronic \(ratio)) - watch fatigue") + case .neutral: return String(localized: "in the sweet spot (acute:chronic \(ratio))") + } + } + switch (signal.key, signal.flag) { + case ("hrv", .good): return String(localized: "above your baseline - well recovered") + case ("hrv", .neutral), ("rhr", .neutral): return String(localized: "in your normal range") + case ("hrv", .watch): return String(localized: "a touch below baseline") + case ("hrv", .bad): return String(localized: "suppressed - a sign of autonomic fatigue") + case ("rhr", .good): return String(localized: "at or below baseline") + case ("rhr", .watch): return String(localized: "running a little high") + case ("rhr", .bad): return String(localized: "elevated - overtraining or illness can do this") + case ("respRate", .bad): return String(localized: "up vs baseline - sometimes an early sign of getting sick") + case ("respRate", .watch): return String(localized: "slightly raised vs baseline") + case ("monotony", _): return String(localized: "low - similar strain every day raises strain/illness risk") + default: return String(localized: "in your normal range") + } + } + + private func readinessNumber(_ value: Double, decimals: Int) -> String { + decimals == 0 + ? String(Int(value.rounded())) + : String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, value) + } + private func levelWord(_ l: ReadinessEngine.Level) -> String { switch l { case .primed: return String(localized: "Primed") @@ -2423,8 +2536,8 @@ struct TodayView: View { // bounded carry reads the same column, so anything the tail could still surface is by // definition older than the window deliberately excludes. A gap now reads "—", which is the // truthful answer when nobody measured. - return withUnit(d?.respRateBpm.map { String(format: "%.1f", $0) } - ?? lastRespDay?.respRateBpm.map { String(format: "%.1f", $0) } ?? "—") + return withUnit(d?.respRateBpm.map { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) } + ?? lastRespDay?.respRateBpm.map { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) } ?? "—") case .bloodOxygen: // PER-FIELD carry: today → whole-row vitals carry → the last row that actually HAS a reading // (computed "-noop" rows write spo2Pct = nil), so this card agrees with the Key Metrics tile @@ -2432,9 +2545,9 @@ struct TodayView: View { // #103: when no calibrated spo2Pct exists AND the experimental toggle is ON, fall back to the // spo2_candidate @82 sparkline tail (strap estimate, unverified) so the card shows a number. let calibrated = (d?.spo2Pct ?? lastVitalsDay?.spo2Pct ?? lastSpo2Day?.spo2Pct) - if let v = calibrated { return String(format: "%.0f%%", v) } + if let v = calibrated { return String(format: "%.0f%%", locale: AppLanguage.activeLocale, v) } if PuffinExperiment.spo2CandidateDisplayEnabled, let tail = sparks["spo2_candidate"]?.last { - return String(format: "%.0f%%", tail) + return String(format: "%.0f%%", locale: AppLanguage.activeLocale, tail) } return "—" case .skinTemp: @@ -2653,7 +2766,7 @@ struct TodayView: View { metricRow(icon: "lungs.fill", label: "Respiratory", // Today's own respiratory, else the carried night's; a non-carrying today keeps the // sparkline-tail fallback so a sparse-but-recent value still reads. - value: resp.map { String(format: "%.1f", $0) } + value: resp.map { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) } ?? (vd == nil ? latestString("resp_rate", decimals: 1) : "—"), unit: "rpm", tint: StrandPalette.accent) @@ -3008,7 +3121,7 @@ struct TodayView: View { private func effortRing(d: DailyMetric?, diameter: CGFloat) -> some View { if effortStrain(d) != nil, let gv = effortGaugeValue(d) { GlowRing(fraction: gv / effortGaugeMax, value: gv, - format: { effortScale == .whoop ? String(format: "%.1f", $0) : "\(Int($0.rounded()))" }, + format: { effortScale == .whoop ? String(format: "%.1f", locale: AppLanguage.activeLocale, $0) : "\(Int($0.rounded()))" }, color: StrandPalette.effortColor, diameter: diameter, lineWidth: diameter * 0.10) } else { emptyHeroRing(diameter: diameter) { ringNoData(diameter: diameter) } @@ -3206,8 +3319,8 @@ struct TodayView: View { tint: StrandPalette.metricRose ) { Text(selectedDayOffset == 0 - ? "Your curve fills in as the strap offloads its history." - : "Step back to a day the strap was worn.") + ? String(localized: "Your curve fills in as the strap offloads its history.") + : String(localized: "Step back to a day the strap was worn.")) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, alignment: .center) @@ -3229,9 +3342,13 @@ struct TodayView: View { .font(StrandFont.footnote.weight(.semibold)) .accessibilityHidden(true) #if os(macOS) - Text(hrZoomDomain == nil ? "Drag to pan · double-tap to reset" : "Zoomed in · drag to pan") + Text(hrZoomDomain == nil + ? String(localized: "Drag to pan · double-tap to reset") + : String(localized: "Zoomed in · drag to pan")) #else - Text(hrZoomDomain == nil ? "Pinch to zoom · drag to pan" : "Zoomed in · drag to pan") + Text(hrZoomDomain == nil + ? String(localized: "Pinch to zoom · drag to pan") + : String(localized: "Zoomed in · drag to pan")) #endif Spacer() if hrZoomDomain != nil { @@ -3401,7 +3518,9 @@ struct TodayView: View { withAnimation(StrandMotion.interactive) { metricsExpanded.toggle() } } label: { HStack(spacing: 6) { - Text(metricsExpanded ? "Show fewer" : "Show all metrics") + Text(metricsExpanded + ? String(localized: "Show fewer") + : String(localized: "Show all metrics")) .font(StrandFont.footnote) if !metricsExpanded { Text("\(hidden)") @@ -3417,7 +3536,9 @@ struct TodayView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(metricsExpanded ? "Show fewer metrics" : "Show all metrics, \(hidden) more") + .accessibilityLabel(metricsExpanded + ? String(localized: "Show fewer metrics") + : String(localized: "Show all metrics, \(hidden) more")) } /// A carried recovery-vital tile's (value, caption): today's own value wins (with the metric's @@ -3549,7 +3670,7 @@ struct TodayView: View { // the last row that actually has a reading. Mirrors the Android Blood Oxygen tile's spo2CarryDay. let spo2 = carriedVital(unit: "SpO₂", today: d?.spo2Pct, prior: { $0.spo2Pct }, perField: lastSpo2Day, - format: { String(format: "%.0f%%", $0) }) + format: { String(format: "%.0f%%", locale: AppLanguage.activeLocale, $0) }) // #103: SpO₂ candidate @82 fallback. When spo2Pct is nil (WHOOP 5/MG BLE-only, no import) AND // the experimental toggle is ON, surface the strap's own @82 nightly mean as a "strap estimate // (unverified)" so the tile shows a number instead of "—". The candidate has split cross-device @@ -3562,7 +3683,7 @@ struct TodayView: View { let spo2CandidateOn = PuffinExperiment.spo2CandidateDisplayEnabled let candidateTail = spo2CandidateOn ? sparks["spo2_candidate"]?.last : nil let spo2Value = spo2.value == "—" && candidateTail != nil - ? String(format: "%.0f%%", candidateTail!) + ? String(format: "%.0f%%", locale: AppLanguage.activeLocale, candidateTail!) : spo2.value let spo2Caption: String = spo2.value == "—" && candidateTail != nil ? String(localized: "strap estimate (unverified)") @@ -3581,7 +3702,7 @@ struct TodayView: View { // Respiratory keeps its sparkline-tail fallback for a NON-carrying today (a sparse-but-recent // value still reads); when carrying, the prior scored night's respiratory is shown + stamped. let respCarry = carriedVital(unit: "rpm", today: d?.respRateBpm, - prior: { $0.respRateBpm }, format: { String(format: "%.1f", $0) }) + prior: { $0.respRateBpm }, format: { String(format: "%.1f", locale: AppLanguage.activeLocale, $0) }) let respValue = respCarry.value == "—" && lastScoredRecoveryDay == nil ? latestString("resp_rate", decimals: 1) : respCarry.value StatTile( @@ -3720,17 +3841,19 @@ struct TodayView: View { .accessibilityLabel("Hide data source detail") Divider().overlay(StrandPalette.hairline) sourceRow( - badge: "Whoop", + badge: Self.whoopBrandName, tint: StrandPalette.accent, present: !repo.days.isEmpty, - detail: String(localized: "\(repo.days.count) days · \(repo.sleeps.count) sleeps") + detail: localizedCountPair(localizedDayCount(repo.days.count), + localizedSleepCount(repo.sleeps.count)) ) Divider().overlay(StrandPalette.hairline) sourceRow( badge: "Apple Health", tint: StrandPalette.metricCyan, present: !appleDays.isEmpty, - detail: String(localized: "\(appleDays.count) days · \(workouts.filter { WorkoutSource.isAppleHealth($0.source) }.count) workouts") + detail: localizedCountPair(localizedDayCount(appleDays.count), + localizedWorkoutCount(workouts.filter { WorkoutSource.isAppleHealth($0.source) }.count)) ) if xiaomiDays > 0 { Divider().overlay(StrandPalette.hairline) @@ -3738,7 +3861,8 @@ struct TodayView: View { badge: "Mi Band", tint: StrandPalette.metricAmber, present: true, - detail: String(localized: "\(xiaomiDays) days · \(xiaomiSleeps) sleeps") + detail: localizedCountPair(localizedDayCount(xiaomiDays), + localizedSleepCount(xiaomiSleeps)) ) } strapBatteryRow @@ -3752,6 +3876,22 @@ struct TodayView: View { } } + private func localizedDayCount(_ count: Int) -> String { + String(localized: "\(count) days") + } + + private func localizedSleepCount(_ count: Int) -> String { + String(localized: "\(count) sleeps") + } + + private func localizedWorkoutCount(_ count: Int) -> String { + String(localized: "\(count) workouts") + } + + private func localizedCountPair(_ first: String, _ second: String) -> String { + String(localized: "\(first) · \(second)") + } + /// S5: the collapsed Data Sources footer: a single "Synced from: WHOOP, Apple Watch >" line that taps /// to expand the full per-source rows. Lists only the sources that actually have data (so a strap-only /// user doesn't read "Apple Health"), and falls back to an honest "No sources yet" when nothing's banked. @@ -4505,7 +4645,7 @@ struct TodayView: View { /// can't be read off `appleDailyRows` (e.g. respiratory from apple-health). private func latestString(_ key: String, decimals: Int, unit: String = "") -> String { guard let last = sparks[key]?.last else { return "—" } - let n = decimals == 0 ? intString(last) : String(format: "%.\(decimals)f", last) + let n = decimals == 0 ? intString(last) : String(format: "%.\(decimals)f", locale: AppLanguage.activeLocale, last) return unit.isEmpty ? n : "\(n) \(unit)" } @@ -4537,20 +4677,16 @@ struct TodayView: View { } } - // #perf: fixed-locale (en_US_POSIX), hoisted to static so the ~1 Hz Today body doesn't allocate a - // DateFormatter every render. Behaviour-identical — the format + locale are pinned. - private static let dateLineFmt: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "EEEE, d MMMM" - return f - }() private var dateLine: String { // The selected day's date when navigated; today's banked-row date (or today) at offset 0. if selectedDayOffset == 0, let day = repo.today?.day, let date = Self.dayParser.date(from: day) { - return Self.dateLineFmt.string(from: date) + return date.formatted( + .dateTime.weekday(.wide).day().month(.wide).locale(AppLanguage.activeLocale) + ) } - return Self.dateLineFmt.string(from: selectedLogicalDay) + return selectedLogicalDay.formatted( + .dateTime.weekday(.wide).day().month(.wide).locale(AppLanguage.activeLocale) + ) } /// Hero title that names the selected day, "Today's"/"Yesterday's"/"Day's" Synthesis. @@ -4568,10 +4704,10 @@ struct TodayView: View { case 0: return String(localized: "Today") case 1: return String(localized: "Yesterday") default: - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "EEE d MMM" - return f.string(from: selectedLogicalDay) + return selectedLogicalDay.formatted( + .dateTime.weekday(.abbreviated).day().month(.abbreviated) + .locale(AppLanguage.activeLocale) + ) } } @@ -4644,7 +4780,7 @@ struct TodayView: View { private func sleepSourceSubtitle(_ d: DailyMetric?) -> String? { guard let d, d.totalSleepMin != nil else { return nil } let source = repo.importedSleep[d.day] != nil - ? String(localized: "Whoop") : String(localized: "On-device") + ? Self.whoopBrandName : String(localized: "On-device") // At offset 0 the row IS last night; a navigated past day names its real date so the label never // over-claims "last night". let night = selectedDayOffset == 0 @@ -4658,7 +4794,7 @@ struct TodayView: View { /// duration is banked, and to nil so the tile shows no caption line at all when neither exists. private func restCaption(_ d: DailyMetric?) -> String? { if d?.totalSleepMin != nil { return sleepValue(d) } - return d?.efficiency.map { String(format: String(localized: "%.0f%% eff"), $0) } + return d?.efficiency.map { String(format: String(localized: "%.0f%% eff"), locale: AppLanguage.activeLocale, $0) } } /// Short "it's coming, not broken" caption for an unscored Effort/Rest tile on TODAY only. The @@ -4719,17 +4855,11 @@ struct TodayView: View { /// "d MMM · HH:mm–HH:mm", start-only when the row has no real end (#157). The "· N bpm" /// segment was dropped: the StatTile caption is lineLimit(1) and date + range + bpm clips, /// avg HR remains on the Workouts screen. - // #perf: fixed-locale (en_US_POSIX), hoisted to static so a workout list doesn't allocate a - // DateFormatter per row per render. Behaviour-identical — format + locale pinned. (mirrors `hrTimeFmt`) - private static let workoutDateFmt: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "d MMM" - return f - }() private func workoutCaption(_ w: WorkoutRow) -> String { let start = Date(timeIntervalSince1970: TimeInterval(w.startTs)) - let date = Self.workoutDateFmt.string(from: start) + let date = start.formatted( + .dateTime.day().month(.abbreviated).locale(AppLanguage.activeLocale) + ) guard w.endTs > w.startTs else { return "\(date) · \(Self.hrTimeFmt.string(from: start))" } let end = Date(timeIntervalSince1970: TimeInterval(w.endTs)) return "\(date) · \(Self.hrTimeFmt.string(from: start))-\(Self.hrTimeFmt.string(from: end))" diff --git a/Tools/i18n_audit_baseline.json b/Tools/i18n_audit_baseline.json index cb5e16c6d2..3432a0e4d1 100644 --- a/Tools/i18n_audit_baseline.json +++ b/Tools/i18n_audit_baseline.json @@ -708,66 +708,6 @@ "android/app/src/main/java/com/noop/ui/TestCentreScreen.kt", "Share captured log" ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "A training read, separate from your Charge score." - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "CALIBRATING" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Calibrating , no heart rate banked yet today. Your curve fills in as the strap offloads." - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "How ${it.label} is calculated" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "How this score is calculated" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "No heart rate for this day. Step back to a day the strap was worn." - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "No heart rate in the last ${hrWindow.label}. Try a wider window or Today." - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Not connected" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Pinch to zoom · drag to pan" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "SOLID" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Show all metrics ($hidden)" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Show fewer" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Strap battery" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Strap battery ${it.roundToInt()} percent" - ], - [ - "android/app/src/main/java/com/noop/ui/TodayScreen.kt", - "Zoomed in · drag to pan" - ], [ "android/app/src/main/java/com/noop/ui/TrendsScreen.kt", "Share recap" @@ -962,10 +902,6 @@ "Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift", "\\(value), \\(label!)" ], - [ - "Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift", - "Pick a date" - ], [ "Packages/StrandDesign/Sources/StrandDesign/YearHeatStrip.swift", "\\(DateFormatterCache.day.string(from: day.date)) · recovery \\(Int(score.rounded()))" @@ -974,10 +910,6 @@ "Strand/Liquid/LiquidPrimitives.swift", "%.\\(decimals)f" ], - [ - "Strand/Liquid/LiquidTodayView.swift", - " \\(unit)" - ], [ "Strand/Screens/AddDeviceWizard.swift", "The most common cause is the ring was not fully reset in the Oura app, or the Oura app is still running. Reset the ring again, force-quit Oura, then try once more. If it keeps failing, your ring may be a generation NOOP cannot adopt yet. The ring is not bricked: re-pair it in the Oura app to recover it. You can still use file import." @@ -1158,10 +1090,6 @@ "Strand/Screens/TestCentreView.swift", "\\(identity). Logs this to the strap log on each connect, so a Polar bug report shows the model NOOP resolved your strap to." ], - [ - "Strand/Screens/TodayView.swift", - "Acute (7-day) vs chronic (28-day) training load. 0.8-1.3 is the sweet spot." - ], [ "Strand/Screens/TrendsView.swift", "Opens the full Charge metric." diff --git a/Tools/i18n_echo_baseline.txt b/Tools/i18n_echo_baseline.txt index 1283950cba..95318ab446 100644 --- a/Tools/i18n_echo_baseline.txt +++ b/Tools/i18n_echo_baseline.txt @@ -22,4 +22,4 @@ android/app/src/main/res/values-es/strings.xml es 42 android/app/src/main/res/values-fr/strings.xml fr 13 android/app/src/main/res/values-pl/strings.xml pl 11 android/app/src/main/res/values-pt-rPT/strings.xml pt-rPT 8 -android/app/src/main/res/values-zh/strings.xml zh 16 +android/app/src/main/res/values-zh/strings.xml zh 15 diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt index 9b0b4605be..c987dbea30 100644 --- a/Tools/i18n_extra_locale_baseline.txt +++ b/Tools/i18n_extra_locale_baseline.txt @@ -14,8 +14,8 @@ NOOPWatch/Localizable.xcstrings:zh-Hant 0 NOOPWatchComplications/Localizable.xcstrings:it 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hans 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hant 25 -Strand/Resources/Localizable.xcstrings:it 182 -Strand/Resources/Localizable.xcstrings:ru 159 -Strand/Resources/Localizable.xcstrings:zh-Hans 132 -Strand/Resources/Localizable.xcstrings:zh-Hant 182 -values-zh/strings.xml 50 +Strand/Resources/Localizable.xcstrings:it 181 +Strand/Resources/Localizable.xcstrings:ru 158 +Strand/Resources/Localizable.xcstrings:zh-Hans 131 +Strand/Resources/Localizable.xcstrings:zh-Hant 181 +values-zh/strings.xml 49 diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py new file mode 100644 index 0000000000..1a776520a9 --- /dev/null +++ b/Tools/test_home_i18n.py @@ -0,0 +1,522 @@ +#!/usr/bin/env python3 +"""Focused zero-baseline localization guard for the phone Home/Today surfaces. + +This intentionally does not use the repository-wide grandfathered baseline. Once the +Home migration is complete, every finding in this source closure must stay at zero. +Run with:: + + python3 Tools/test_home_i18n.py +""" + +from __future__ import annotations + +import re +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +import i18n_audit as audit + + +ROOT = Path(__file__).resolve().parents[1] + +# The complete Android Today implementation plus the small Today-only helpers it calls. +# AppRoot is deliberately handled separately below: most of that file is the unrelated +# More/navigation UI, while only its Today tab and quick-action resources belong here. +ANDROID_HOME_FILES = { + "android/app/src/main/java/com/noop/ui/TodayScreen.kt", + "android/app/src/main/java/com/noop/ui/TodayDayNav.kt", + "android/app/src/main/java/com/noop/ui/TodayLayoutPrefs.kt", + "android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt", + "android/app/src/main/java/com/noop/ui/TodayProvenance.kt", + "android/app/src/main/java/com/noop/ui/TodayScoring.kt", + "android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt", + "android/app/src/main/java/com/noop/ui/JournalReminder.kt", + "android/app/src/main/java/com/noop/ui/CycleTrackerDialog.kt", + "android/app/src/main/java/com/noop/ui/KeyMetricPrefs.kt", + "android/app/src/main/java/com/noop/ui/DashboardCards.kt", + "android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt", + "android/app/src/main/java/com/noop/analytics/StepsEstimateEngine.kt", + "android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt", +} +ANDROID_SHELL_FILE = "android/app/src/main/java/com/noop/ui/AppRoot.kt" + +# Both selectable Today implementations, their Today-only editor/metadata, the shared +# day picker that they render, and the iPhone shell/icon actions that enter Home. +APPLE_HOME_FILES = { + "Strand/Screens/TodayView.swift", + "Strand/Liquid/LiquidTodayView.swift", + "Strand/Screens/TodayCustomizationSheet.swift", + "Strand/Screens/TodayCustomizationMetadata.swift", + "Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift", + "StrandiOS/System/HomeScreenQuickActions.swift", + "Strand/Screens/AutoWorkoutCard.swift", + "Strand/Screens/JournalReminderCard.swift", + "Strand/Screens/SkinTempCardsView.swift", + "Strand/Screens/HealthAlertBanner.swift", +} +APPLE_SHELL_FILE = "StrandiOS/App/RootTabView.swift" + +# RootTabView also owns the unrelated More tab. Limit its Home contract to the +# Today tab label, the sheet opened from Today's + button, and that sheet's close +# affordance instead of treating every future RootTabView string as Home copy. +APPLE_HOME_SHELL_CATALOG_KEYS = { + "Today", "Done", "QUICK ACTIONS", "Live HR", "Start workout", "Log journal", "Breathe", +} + +# These helpers return display strings through variables, which scan_android cannot +# data-flow back from Text(variable). Scan every literal in them conservatively. The +# allowlist contains only persistence/source identifiers and date formats, never copy. +ANDROID_DISPLAY_HELPERS = { + "android/app/src/main/java/com/noop/ui/TodayLayoutPrefs.kt", + "android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt", + "android/app/src/main/java/com/noop/ui/TodayProvenance.kt", + "android/app/src/main/java/com/noop/ui/TodayScoring.kt", +} + +# These producers feed strings into Home through fields/lambdas, so the general Android scanner cannot +# see the eventual Text(variable). Only stable wire ids, preference keys, units and format specs belong +# in the allowlist; every human phrase must become a semantic/resource contract. +ANDROID_INDIRECT_COPY_FILES = { + "android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt", + "android/app/src/main/java/com/noop/analytics/StepsEstimateEngine.kt", + "android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt", + "android/app/src/main/java/com/noop/ui/KeyMetricPrefs.kt", + "android/app/src/main/java/com/noop/ui/DashboardCards.kt", + "android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt", +} +ANDROID_INDIRECT_NON_UI_LITERALS = { + # Engine keys, metric units and numeric format specs. + "hrv", "rhr", "respRate", "acwr", "monotony", "ms", "bpm", "rpm", "%.1f", + # Auto-workout source/wire values. Workout is the persisted generic sport tag, not rendered copy. + "my-whoop", "Workout", "apple-health", "health-connect", "lifting", "autoDetect", + # Stable KeyMetric raw values + preferences. + "charge", "effort", "rest", "restingHr", "bloodOxygen", "respiratory", "steps", + "weight", "calories", "today.keyMetrics", "today.keyMetricsDetailed", + "today.keyMetricsWindowDays", ",", + # Stable DashboardCard raw values + preference; units remain measurement metadata. + "stress", "fitnessAge", "vo2max", "vitality", "skinTemp", "sleep", "hydration", "coupled", + "today.dashboardCards", "yrs", "kcal", "", +} + +# Confirmed runtime copy that reaches Today through variables, lambdas, model fields or accessibility +# aggregation. The normal Compose scanner cannot follow those producer paths, so pin every confirmed +# phrase until it has a resource/semantic contract. Wire ids and format strings are intentionally absent. +ANDROID_TODAY_RUNTIME_COPY = { + "Today", "Yesterday", "day", "days", + "Session running", "Session ended", "Start session", + "Guarding — silence means you're on track.", "See the summary of your last session.", + "Strap-guided effort session. It only buzzes when you drift off today's band.", "BETA", + "Building your baseline", "Charge, Effort and Rest become personal after a few nights of wear.", + "Live now. Your scores are building.", "Charge, Effort and Rest build over your next few nights of wear.", + "Cards with no value yet show a dash.", "SOLID", "BUILDING", "CALIBRATING", + "5-minute average | selected day", "5-minute average | since midnight", + "24-hour heart rate", "Still", "Walking", "Running", + "Good morning", "Good afternoon", "Good evening", "No Data", "Calibrating", + "7-day trend", "14-day trend", "30-day trend", "1 week", "2 weeks", "1 month", +} +ANDROID_HELPER_NON_UI_LITERALS = { + # TodayLayoutPrefs stable backup/persistence wire values. + "hero", "liveSession", "synthesis", "keyMetrics", "workouts", "heartRate", + "recoveryVitals", "yourCards", "menstrualCycle", "journal", "addedCards", + "today.sectionOrder", "today.hiddenSections", + # TodayScoring parse/default formats. + "9999-12-31", "d MMM", "24h", "12h", "6h", "3h", "1h", + "<1m", "${secs / 60}m", "${secs / 3600}h", "${secs / 86_400}d", + # TodayProvenance source ids and metric dictionary keys. + "-noop", "recovery", "strain", "sleep_performance", "oura-import", "oura-api", + "fitbit-import", "garmin-import", "xiaomi-band", +} + +# AppRoot's Home-owned shell strings are resource-backed: the visible Today bottom-tab +# label and the four actions opened from Today's + button. Do not gate unrelated AppRoot +# findings (for example the More-page expanded/collapsed accessibility state). +ANDROID_HOME_SHELL_RESOURCES = { + "nav_today", "action_live_hr", "action_start_workout", "action_log_journal", + "action_breathe", +} + +R_STRING = re.compile(r"\bR\.string\.([A-Za-z_][A-Za-z0-9_]*)") +SWIFT_LOCALIZED_CALL = re.compile(r"\b(?:String\s*\(\s*localized:|LocalizedStringKey\s*\()\s*\"") + + +def _format_findings(rows: list[tuple[str, int, str]]) -> str: + return "\n".join(f"{path}:{line}: {literal!r}" for path, line, literal in rows) + + +def _all_kotlin_literals(path: Path) -> list[tuple[int, str]]: + """All non-comment string literals, using the audit's template-aware lexer.""" + text = audit._mask_comments(path.read_text(encoding="utf-8")) + result: list[tuple[int, str]] = [] + i = 0 + while i < len(text): + if text[i] != '"': + i += 1 + continue + end = audit._skip_string_literal(text, i) + result.append((text.count("\n", 0, i) + 1, text[i + 1:end - 1])) + i = end + return result + + +def _is_helper_copy(literal: str) -> bool: + """Stricter than the general audit for display-producing helper files. + + Lower-case one-word values such as ``latest``/``night`` are normally + indistinguishable from ids, but in this tiny closure the real ids are all + enumerated in ANDROID_HELPER_NON_UI_LITERALS, so they must be gated too. + """ + return bool(re.search(r"[A-Za-z]", literal)) and not audit.PURE_FORMAT_SPEC.fullmatch(literal) + + +def _apple_catalog_for(path: str) -> Path: + if path.startswith("Packages/StrandDesign/"): + return ROOT / "Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings" + return ROOT / "Strand/Resources/Localizable.xcstrings" + + +def _localized_swift_literals(path: Path) -> set[str]: + """Catalog-backed literals used by SwiftUI or explicit String(localized:).""" + text = path.read_text(encoding="utf-8") + literals = {literal for _, literal in audit.swift_string_literals(text)} + for match in SWIFT_LOCALIZED_CALL.finditer(text): + quote = match.end() - 1 + end = audit._skip_swift_string_literal(text, quote) + literals.add(text[quote + 1:end - 1]) + return {literal for literal in literals if audit.is_probably_ui_text(literal)} + + +def _android_resource_names(path: Path) -> set[str]: + return set(R_STRING.findall(audit._mask_comments(path.read_text(encoding="utf-8")))) + + +class HomeLocalizationTest(unittest.TestCase): + maxDiff = None + + def test_android_home_has_no_audit_findings(self) -> None: + findings = [row for row in audit.scan_android() if row[0] in ANDROID_HOME_FILES] + self.assertEqual([], findings, "Unlocalized Android Home UI:\n" + _format_findings(findings)) + + def test_android_display_helpers_have_no_raw_ui_copy(self) -> None: + findings: list[tuple[str, int, str]] = [] + for relative in sorted(ANDROID_DISPLAY_HELPERS): + for line, literal in _all_kotlin_literals(ROOT / relative): + if literal in ANDROID_HELPER_NON_UI_LITERALS: + continue + if _is_helper_copy(literal): + findings.append((relative, line, literal)) + self.assertEqual([], findings, "Raw Android Home helper copy:\n" + _format_findings(findings)) + + def test_android_indirect_home_producers_have_no_raw_ui_copy(self) -> None: + findings: list[tuple[str, int, str]] = [] + for relative in sorted(ANDROID_INDIRECT_COPY_FILES): + for line, literal in _all_kotlin_literals(ROOT / relative): + if literal in ANDROID_INDIRECT_NON_UI_LITERALS: + continue + if _is_helper_copy(literal): + findings.append((relative, line, literal)) + self.assertEqual([], findings, "Raw indirect Android Home copy:\n" + _format_findings(findings)) + + def test_android_today_runtime_producer_copy_is_resource_backed(self) -> None: + relative = "android/app/src/main/java/com/noop/ui/TodayScreen.kt" + findings = [ + (relative, line, literal) + for line, literal in _all_kotlin_literals(ROOT / relative) + if literal in ANDROID_TODAY_RUNTIME_COPY + ] + self.assertEqual([], findings, "Raw Today runtime producer copy:\n" + _format_findings(findings)) + + def test_android_score_section_label_is_resolved_at_ui_boundary(self) -> None: + relative = "android/app/src/main/java/com/noop/ui/ScoringGuideScreen.kt" + source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) + self.assertNotRegex(source, r"val\s+label\s*:\s*String", "ScoreSection must expose a resource contract") + + def test_android_today_source_counts_use_two_plural_resources(self) -> None: + relative = "android/app/src/main/java/com/noop/ui/TodayScreen.kt" + source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) + self.assertIn("R.plurals.today_source_days", source) + self.assertIn("R.plurals.today_source_workouts", source) + self.assertNotIn("R.string.today_source_counts", source) + + def test_android_english_home_contract_preserves_head_semantics(self) -> None: + root = ET.parse(ROOT / "android/app/src/main/res/values/strings.xml").getroot() + strings = {node.attrib["name"]: (node.text or "").replace("\\'", "'") for node in root.findall("string")} + expected = { + "today_card_hrv": "HRV", "today_card_hrv_subtitle": "Heart-rate variability", + "today_card_resting_hr": "Resting HR", "today_card_resting_hr_subtitle": "Resting heart rate", + "today_card_respiratory": "Respiratory", "today_card_respiratory_subtitle": "Breaths per minute", + "today_card_steps": "Steps", "today_card_steps_subtitle": "Today", + "today_card_stress": "Stress", "today_card_stress_subtitle": "Autonomic load", + "today_card_fitness_age": "Fitness Age", "today_card_fitness_age_subtitle": "Updated weekly", + "today_card_vo2max": "VO₂ Max", "today_card_vo2max_subtitle": "Estimated, updated weekly", + "today_card_vitality": "Vitality", "today_card_vitality_subtitle": "Wellness score", + "today_card_blood_oxygen": "Blood Oxygen", "today_card_blood_oxygen_subtitle": "Blood oxygen", + "today_card_skin_temp": "Skin Temp", "today_card_skin_temp_subtitle": "Skin temperature", + "today_card_sleep": "Sleep", "today_card_sleep_subtitle": "Last night", + "today_card_calories": "Calories", "today_card_calories_subtitle": "Active energy", + "today_card_hydration": "Hydration", "today_card_hydration_subtitle": "Today's fluid", + "today_card_coupled": "Coupled view", + "today_card_coupled_subtitle": "Recovery, strain and sleep in one glance", + "today_readiness_wear_for_nights": "Wear the strap for a few nights and your readiness read will appear here.", + "today_readiness_hrv_good": "above your baseline - well recovered", + "today_readiness_normal_range": "in your normal range", + "today_readiness_hrv_watch": "a touch below baseline", + "today_readiness_hrv_bad": "suppressed - a sign of autonomic fatigue", + "today_readiness_rhr_good": "at or below baseline", + "today_readiness_rhr_watch": "running a little high", + "today_readiness_rhr_bad": "elevated - overtraining or illness can do this", + "today_readiness_resp_bad": "up vs baseline - sometimes an early sign of getting sick", + "today_readiness_resp_watch": "slightly raised vs baseline", + "today_readiness_monotony_watch": "low - similar strain every day raises strain/illness risk", + "today_readiness_load_ramping_down": "ramping down (acute:chronic %1$s) - room to build", + "today_readiness_load_sweet_spot": "in the sweet spot (acute:chronic %1$s)", + "today_readiness_load_building_fast": "building fast (acute:chronic %1$s) - watch fatigue", + "today_readiness_load_spiking": "spiking (acute:chronic %1$s) - higher injury risk", + "today_readiness_more_nights": "A few more nights of data and your readiness read will sharpen.", + "today_readiness_run_down_summary": "Several signals are down at once. Treat today as recovery - easy movement, real sleep tonight.", + "today_readiness_strained_summary": "One of your signals is flagging. You can train, but keep it controlled and bank the recovery.", + "today_readiness_primed_summary": "Your signals are aligned and your load is supported. A harder session is well backed today.", + "today_readiness_balanced_summary": "Nothing's flagging. Train to feel - your body's holding steady.", + "today_readiness_evidence_monotony": "monotony %1$s", + } + self.assertEqual({}, {key: (strings.get(key), value) for key, value in expected.items() if strings.get(key) != value}) + + def test_android_today_chrome_and_click_labels_are_resource_backed(self) -> None: + relative = "android/app/src/main/java/com/noop/ui/TodayScreen.kt" + source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) + patterns = { + "raw SectionHeader/Overline": re.compile(r"\b(?:SectionHeader|Overline)\s*\(\s*\""), + "raw onClickLabel": re.compile(r"\bonClickLabel\s*=\s*\""), + } + findings = [] + for kind, pattern in patterns.items(): + findings.extend( + (relative, source.count("\n", 0, match.start()) + 1, kind) + for match in pattern.finditer(source) + ) + self.assertEqual([], findings, "Raw Today chrome/a11y copy:\n" + _format_findings(findings)) + + def test_android_home_display_formatting_is_not_pinned_to_us_locale(self) -> None: + display_files = ANDROID_INDIRECT_COPY_FILES | ANDROID_DISPLAY_HELPERS | { + "android/app/src/main/java/com/noop/ui/TodayScreen.kt", + } + findings = [] + for relative in sorted(display_files): + source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) + findings.extend( + (relative, source.count("\n", 0, match.start()) + 1, "Locale.US") + for match in re.finditer(r"\b(?:java\.util\.)?Locale\.US\b", source) + ) + self.assertEqual([], findings, "US-pinned Android Home display formatting:\n" + _format_findings(findings)) + + def test_android_source_joiner_preserves_spaces_through_aapt(self) -> None: + root = ET.parse(ROOT / "android/app/src/main/res/values/strings.xml").getroot() + node = next(n for n in root.findall("string") if n.attrib.get("name") == "today_source_joiner") + self.assertEqual('" + "', node.text, "Quote source_joiner so aapt preserves both spaces") + + def test_android_home_resources_cover_focus_locales(self) -> None: + used = set(ANDROID_HOME_SHELL_RESOURCES) + for relative in ANDROID_HOME_FILES: + used.update(_android_resource_names(ROOT / relative)) + + paths = {"en": ROOT / "android/app/src/main/res/values/strings.xml"} + paths.update({ + lang: ROOT / f"android/app/src/main/res/{directory}/strings.xml" + for lang, directory in audit.ANDROID_LOCALE_DIRS.items() + }) + missing: list[str] = [] + for lang, path in paths.items(): + names = {node.attrib["name"] for node in ET.parse(path).getroot() if node.tag in {"string", "plurals"}} + missing.extend(f"{lang}: {name}" for name in sorted(used - names)) + self.assertEqual([], missing, "Missing Android Home resources:\n" + "\n".join(missing)) + + def test_android_home_locale_context_values_are_natural_and_complete(self) -> None: + expected = { + "pl": { + "l10n_today_screen_no_cardio_load_yet_effort_builds_e952006c": + "Nie ma jeszcze obciążenia kardio. Wysiłek zwiększa się, gdy tętno osiągnie strefę wysiłku (około 50% rezerwy tętna). Spokojny dzień szczerze odczytuje się w pobliżu zera.", + }, + "zh": { + "l10n_today_screen_edit_key_metrics_f95e61a4": "编辑关键指标", + "l10n_today_screen_no_new_nights_from_your_strap_for_stale_days_8863bcfe": + "你的手环已有 %1$d 天没有记录到新的夜间数据。请检查它是否已连接并正在保存数据。", + }, + "pt-rPT": {"l10n_today_screen_synthesis_876bc749": "SÍNTESE"}, + "es": {"l10n_today_screen_unitformatter_effortdisplay_strain_effortscale_effort_53dbd951": "%1$s Esfuerzo"}, + } + mismatches = {} + for directory, locale_values in expected.items(): + root = ET.parse(ROOT / f"android/app/src/main/res/values-{directory}/strings.xml").getroot() + strings = {node.attrib["name"]: node.text or "" for node in root.findall("string")} + for key, value in locale_values.items(): + if strings.get(key) != value: + mismatches[f"{directory}:{key}"] = (strings.get(key), value) + self.assertEqual({}, mismatches) + + def test_apple_home_has_no_audit_findings(self) -> None: + findings, _ = audit.scan_ios() + scoped = [row for row in findings if row[0] in APPLE_HOME_FILES] + self.assertEqual([], scoped, "Unlocalized Apple Home UI:\n" + _format_findings(scoped)) + + def test_apple_home_catalog_entries_cover_focus_locales(self) -> None: + missing: list[str] = [] + catalogs: dict[Path, dict] = {} + for relative in sorted(APPLE_HOME_FILES): + catalog_path = _apple_catalog_for(relative) + catalog = catalogs.setdefault(catalog_path, audit.load_catalog(catalog_path)) + for literal in sorted(_localized_swift_literals(ROOT / relative)): + entry = audit.swift_catalog_lookup(catalog, literal) + if entry is None: + # The source-finding test reports this with a useful line number. + continue + for lang in audit.LANGS: + if not audit._is_translated(entry, lang): + missing.append(f"{relative}: {lang}: {literal!r}") + + shell_catalog_path = _apple_catalog_for(APPLE_SHELL_FILE) + shell_catalog = catalogs.setdefault(shell_catalog_path, audit.load_catalog(shell_catalog_path)) + for literal in sorted(APPLE_HOME_SHELL_CATALOG_KEYS): + entry = audit.swift_catalog_lookup(shell_catalog, literal) + if entry is None: + missing.append(f"{APPLE_SHELL_FILE}: all: {literal!r} absent from catalog") + continue + for lang in audit.LANGS: + if not audit._is_translated(entry, lang): + missing.append(f"{APPLE_SHELL_FILE}: {lang}: {literal!r}") + self.assertEqual([], missing, "Missing Apple Home catalog translations:\n" + "\n".join(missing)) + + def test_apple_skin_temp_dynamic_copy_uses_swift_interpolation(self) -> None: + source = (ROOT / "Strand/Screens/SkinTempCardsView.swift").read_text(encoding="utf-8") + for argument in ("hours", "signals", "reasons"): + self.assertIn( + rf"\({argument})", + source, + f"Skin-temperature localized copy must interpolate {argument} with Swift syntax", + ) + + def test_apple_whoop_brand_and_tint_are_locale_independent(self) -> None: + source = (ROOT / "Strand/Screens/TodayView.swift").read_text(encoding="utf-8") + catalog = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings") + self.assertEqual("WHOOP", catalog["strings"]["Whoop"]["localizations"]["pt-PT"]["stringUnit"]["value"]) + self.assertIn('private static let whoopBrandName = "WHOOP"', source) + + tint_start = source.index("private func provenanceTint") + tint_end = source.index("// MARK: Apple Watch provenance", tint_start) + self.assertNotIn("provenanceLabel(", source[tint_start:tint_end]) + + def test_apple_liquid_runtime_copy_and_pt_terms_are_localized(self) -> None: + source = (ROOT / "Strand/Liquid/LiquidTodayView.swift").read_text(encoding="utf-8") + self.assertIn( + 'private var stressText: String { stress.map { String(Int($0.rounded())) } ?? String(localized: "Calibrating") }', + source, + ) + self.assertIn('return "\\(base) · \\(String(localized: \"Charging\"))"', source) + + strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] + expected = { + "Push": "Avançar", + "SYNTHESIS": "SÍNTESE", + "Still": "Parado", + } + for key, value in expected.items(): + self.assertEqual(value, strings[key]["localizations"]["pt-PT"]["stringUnit"]["value"]) + + def test_apple_home_semantic_display_contracts(self) -> None: + app_model = (ROOT / "Strand/App/AppModel.swift").read_text(encoding="utf-8") + illness = (ROOT / "Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift").read_text(encoding="utf-8") + readiness = (ROOT / "Packages/StrandAnalytics/Sources/StrandAnalytics/ReadinessEngine.swift").read_text(encoding="utf-8") + today = (ROOT / "Strand/Screens/TodayView.swift").read_text(encoding="utf-8") + + self.assertIn("public enum Message", illness) + self.assertIn('suppressedBy.append("a hard or late workout")', illness) + self.assertIn("suppressionReasons.append(.hardOrLateWorkout)", illness) + self.assertNotIn('result.copy.contains("numbers agree")', (ROOT / "Strand/Screens/SkinTempCardsView.swift").read_text(encoding="utf-8")) + self.assertIn('String(localized: "RHR +\\(delta)")', app_model) + self.assertIn('String(localized: "HRV −\\(percent)%")', app_model) + self.assertIn('String(localized: "Skin temperature +\\(temperature) °C")', app_model) + self.assertIn('String(localized: "Respiration up")', app_model) + + self.assertIn("public enum Evidence", readiness) + self.assertIn("readinessEvidenceText", today) + self.assertIn("readinessDetailText", today) + self.assertIn('String(format: "%.\\(decimals)f", locale: AppLanguage.activeLocale, value)', today) + self.assertNotIn("if let evidence = s.evidence", today) + self.assertNotIn("LocalizedStringKey(s.detail)", today) + + self.assertIn("badge: Self.whoopBrandName", today) + self.assertIn('case .nutritionCsv: return String(localized: "Nutrition")', today) + self.assertIn('case .localCache: return String(localized: "Cached")', today) + + strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] + for key in ( + "RHR +%lld", "HRV −%lld%%", "Skin temperature +%@ °C", "Respiration up", + "%@ vs %@ %@", "7d %@ / 28d %@", "monotony %@", + ): + self.assertIn(key, strings) + for lang in audit.LANGS: + self.assertTrue(audit._is_translated(strings[key], lang), f"{lang}: {key}") + + def test_apple_pt_home_terms_are_context_correct(self) -> None: + strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] + expected = { + "Strap battery": "Bateria da pulseira", + "Needs the strap": "Requer a pulseira", + "Run down": "Esgotado", + "Rest HR": "FC repouso", + "Resting HR": "FC em repouso", + "Your cards": "Os teus cartões", + "~%lldh left": "Faltam ~%lld h", + "%lld days · %lld sleeps": "%1$lld dias · %2$lld noites de sono", + } + for key, value in expected.items(): + self.assertEqual(value, strings[key]["localizations"]["pt-PT"]["stringUnit"]["value"]) + + def test_apple_home_banner_and_suppression_contract_are_semantic(self) -> None: + app_model = (ROOT / "Strand/App/AppModel.swift").read_text(encoding="utf-8") + banner = (ROOT / "Strand/Screens/HealthAlertBanner.swift").read_text(encoding="utf-8") + illness = (ROOT / "Packages/StrandAnalytics/Sources/StrandAnalytics/IllnessSignalEngine.swift").read_text(encoding="utf-8") + + self.assertIn("struct HealthAlert: Equatable", app_model) + self.assertIn("let message: IllnessSignalEngine.Message", app_model) + self.assertIn("@Published var healthAlert: HealthAlert?", app_model) + self.assertNotIn("? result.copy : nil", app_model) + self.assertIn("localizedHealthAlertCopy", banner) + self.assertNotIn("Text(alert)", banner) + self.assertIn('suppressedBy.append("a hard or late workout")', illness) + self.assertIn("public enum SuppressionReason", illness) + self.assertIn("public let suppressionReasons: [SuppressionReason]", illness) + + def test_apple_home_count_catalogs_have_real_focus_plural_variations(self) -> None: + today = (ROOT / "Strand/Screens/TodayView.swift").read_text(encoding="utf-8") + strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] + self.assertNotIn('String(localized: "\\(repo.days.count) days · \\(repo.sleeps.count) sleeps")', today) + self.assertIn("localizedDayCount", today) + self.assertIn("localizedSleepCount", today) + self.assertIn("localizedWorkoutCount", today) + for key in ("%lld days", "%lld sleeps", "%lld workouts"): + for lang in ("en", *audit.LANGS, "it"): + localization = strings[key].get("localizations", {}).get(lang, {}) + plural = localization.get("variations", {}).get("plural", {}) + self.assertIn("one", plural, f"{lang}: {key} missing singular") + self.assertIn("other", plural, f"{lang}: {key} missing plural") + + def test_apple_illness_messages_are_not_portuguese_in_other_locales(self) -> None: + strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] + keys = ( + "Your body looks strained. Signals up: %@. No alcohol or travel was logged, so consider taking it easy. On-device estimate, not a diagnosis.", + "You logged feeling unwell, and your signals agree. Take it easy today. On-device estimate, not a diagnosis.", + "You logged feeling unwell. Take it easy today. On-device estimate, not a diagnosis.", + "Some signals are up, but you logged %@. That is the more likely explanation. On-device estimate, not a diagnosis.", + "A few signals are mildly up: %@. Nothing alarming, but a calmer day may help. On-device estimate, not a diagnosis.", + "Still learning your baseline and keeping an eye on your signals.", + "Nothing notable. Your signals look like their normal range.", + ) + for key in keys: + localizations = strings[key]["localizations"] + pt = localizations["pt-PT"]["stringUnit"]["value"] + for lang in ("it", "pl", "ru", "zh-Hans", "zh-Hant"): + self.assertNotEqual(pt, localizations[lang]["stringUnit"]["value"], f"{lang}: {key}") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt b/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt index a68bc1fb58..e4e83fdfe1 100644 --- a/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt @@ -1,7 +1,8 @@ package com.noop.analytics +import androidx.annotation.StringRes +import com.noop.R import com.noop.data.DailyMetric -import java.util.Locale import kotlin.math.exp import kotlin.math.ln import kotlin.math.sqrt @@ -45,20 +46,33 @@ object ReadinessEngine { enum class Flag { GOOD, NEUTRAL, WATCH, BAD } + enum class MetricUnit { MS, BPM, RPM } + + sealed interface Evidence { + data class MetricVsBaseline( + val value: Double, + val baseline: Double, + val decimals: Int, + val unit: MetricUnit, + ) : Evidence + data class Monotony(val value: Double) : Evidence + data class TrainingLoad(val acute: Double, val chronic: Double) : Evidence + } + data class Signal( val key: String, // "hrv" | "rhr" | "respRate" | "acwr" | "monotony" - val label: String, // short human label - val detail: String, // one-line plain-English read + @StringRes val labelRes: Int, + @StringRes val detailRes: Int, val flag: Flag, // The numbers behind the signal, e.g. "48 vs 55 ms" or "7d 12.1 / 28d 9.4". Optional and // backward-compatible (defaults null); rendered as a small caption under the signal in the UI. - val evidence: String? = null, + val evidence: Evidence? = null, ) data class Readiness( val level: Level, - val headline: String, - val summary: String, + @StringRes val headlineRes: Int, + @StringRes val summaryRes: Int, val signals: List, /** Acute:chronic workload ratio (null if not enough strain history). */ val acwr: Double?, @@ -160,8 +174,8 @@ object ReadinessEngine { if (latest == null) { return Readiness( level = Level.INSUFFICIENT, - headline = "Readiness", - summary = "Wear the strap for a few nights and your readiness read will appear here.", + headlineRes = R.string.today_readiness_title, + summaryRes = R.string.today_readiness_wear_for_nights, signals = emptyList(), acwr = null, monotony = null, ) } @@ -173,15 +187,15 @@ object ReadinessEngine { val hrvSignal = zSignal( value = latest.avgHrv, baseline = history.takeLast(baselineWindow).mapNotNull { it.avgHrv }, - key = "hrv", label = "HRV", - unit = "ms", decimals = 0, + key = "hrv", labelRes = R.string.today_readiness_signal_hrv, + unit = MetricUnit.MS, decimals = 0, higherIsBetter = true, cfg = Baselines.readinessHRVLnCfg, // RD2: ln-space spine, reject off logDomain = true, // RD1: lnRMSSD — HRV is right-skewed - goodText = "above your baseline - well recovered", - neutralText = "in your normal range", - watchText = "a touch below baseline", - badText = "suppressed - a sign of autonomic fatigue", + goodTextRes = R.string.today_readiness_hrv_good, + neutralTextRes = R.string.today_readiness_normal_range, + watchTextRes = R.string.today_readiness_hrv_watch, + badTextRes = R.string.today_readiness_hrv_bad, ) if (hrvSignal != null) signals.add(hrvSignal) @@ -189,14 +203,14 @@ object ReadinessEngine { val rhrSignal = zSignal( value = latest.restingHr?.toDouble(), baseline = history.takeLast(baselineWindow).mapNotNull { it.restingHr?.toDouble() }, - key = "rhr", label = "Resting HR", - unit = "bpm", decimals = 0, + key = "rhr", labelRes = R.string.today_readiness_signal_resting_hr, + unit = MetricUnit.BPM, decimals = 0, higherIsBetter = false, cfg = Baselines.restingHRCfg, // RD2: raw-bpm spine, reject off - goodText = "at or below baseline", - neutralText = "in your normal range", - watchText = "running a little high", - badText = "elevated - overtraining or illness can do this", + goodTextRes = R.string.today_readiness_rhr_good, + neutralTextRes = R.string.today_readiness_normal_range, + watchTextRes = R.string.today_readiness_rhr_watch, + badTextRes = R.string.today_readiness_rhr_bad, ) if (rhrSignal != null) signals.add(rhrSignal) @@ -213,20 +227,20 @@ object ReadinessEngine { val sd = sampleSD(base) if (base.size >= minBaseline && m != null && m in respPlausibleRange && sd != null && sd > 0) { val z = (rr - m) / sd - val respEvidence = "${fmt(rr, 1)} vs ${fmt(m, 1)} rpm" + val respEvidence = Evidence.MetricVsBaseline(rr, m, 1, MetricUnit.RPM) if (z >= respZBad) { signals.add( Signal( - key = "respRate", label = "Respiratory rate", - detail = "up vs baseline - sometimes an early sign of getting sick", flag = Flag.BAD, + key = "respRate", labelRes = R.string.today_readiness_signal_respiratory, + detailRes = R.string.today_readiness_resp_bad, flag = Flag.BAD, evidence = respEvidence, ) ) } else if (z >= respZWatch) { signals.add( Signal( - key = "respRate", label = "Respiratory rate", - detail = "slightly raised vs baseline", flag = Flag.WATCH, + key = "respRate", labelRes = R.string.today_readiness_signal_respiratory, + detailRes = R.string.today_readiness_resp_watch, flag = Flag.WATCH, evidence = respEvidence, ) ) @@ -256,16 +270,16 @@ object ReadinessEngine { if (mono >= 2.0) { signals.add( Signal( - key = "monotony", label = "Training variety", - detail = "low - similar strain every day raises strain/illness risk", flag = Flag.WATCH, - evidence = "monotony ${fmt(mono, 1)}", + key = "monotony", labelRes = R.string.today_readiness_signal_training_variety, + detailRes = R.string.today_readiness_monotony_watch, flag = Flag.WATCH, + evidence = Evidence.Monotony(mono), ) ) } } } - val (level, headline, summary) = synthesize( + val (level, headlineRes, summaryRes) = synthesize( signals = signals, hasHistory = history.isNotEmpty() || acwr != null, ) @@ -279,7 +293,7 @@ object ReadinessEngine { fullWindow = baselineWindow, ) return Readiness( - level = level, headline = headline, summary = summary, + level = level, headlineRes = headlineRes, summaryRes = summaryRes, signals = signals, acwr = acwr, monotony = monotony, confidence = confidence, ) @@ -290,11 +304,11 @@ object ReadinessEngine { /** Build a z-score signal for a metric where the baseline is the trailing window. */ private fun zSignal( value: Double?, baseline: List, - key: String, label: String, unit: String, decimals: Int, higherIsBetter: Boolean, + key: String, @StringRes labelRes: Int, unit: MetricUnit, decimals: Int, higherIsBetter: Boolean, cfg: MetricCfg, logDomain: Boolean = false, - goodText: String, neutralText: String, - watchText: String, badText: String, + @StringRes goodTextRes: Int, @StringRes neutralTextRes: Int, + @StringRes watchTextRes: Int, @StringRes badTextRes: Int, ): Signal? { if (value == null || baseline.size < minBaseline) return null // RD1: right-skewed metrics (HRV/RMSSD) are z-scored in the LOG domain — lnRMSSD is closer to @@ -321,18 +335,18 @@ object ReadinessEngine { // Orient z so positive always means "better". val z = (if (higherIsBetter) (tv - m) else (m - tv)) / sigma val flag: Flag - val text: String + val detailRes: Int when { - z >= 0.5 -> { flag = Flag.GOOD; text = goodText } - z >= -0.5 -> { flag = Flag.NEUTRAL; text = neutralText } - z >= -1.0 -> { flag = Flag.WATCH; text = watchText } - else -> { flag = Flag.BAD; text = badText } + z >= 0.5 -> { flag = Flag.GOOD; detailRes = goodTextRes } + z >= -0.5 -> { flag = Flag.NEUTRAL; detailRes = neutralTextRes } + z >= -1.0 -> { flag = Flag.WATCH; detailRes = watchTextRes } + else -> { flag = Flag.BAD; detailRes = badTextRes } } // The numbers behind the read: today's value vs the baseline mean, in the metric's units. In the // log domain the baseline shown is the geometric mean (exp of the log-mean), not arithmetic. val baselineShown = if (logDomain) exp(m) else m - val evidence = "${fmt(value, decimals)} vs ${fmt(baselineShown, decimals)} $unit" - return Signal(key = key, label = label, detail = text, flag = flag, evidence = evidence) + val evidence = Evidence.MetricVsBaseline(value, baselineShown, decimals, unit) + return Signal(key = key, labelRes = labelRes, detailRes = detailRes, flag = flag, evidence = evidence) } /** @@ -342,36 +356,30 @@ object ReadinessEngine { * uses "%.Nf" (round-half-to-even) to match Swift's `String(format:)`. Locale.US so the separator * is always ".". */ - private fun fmt(x: Double, decimals: Int): String = - if (decimals == 0) Math.round(x).toString() - else String.format(Locale.US, "%.${decimals}f", x) - private fun acwrSignal(ratio: Double, acute: Double, chronic: Double): Signal { // #1033 (ryanbr): route the acute:chronic ratio through the Locale.US-pinned [fmt] helper (matching // the evidence line below) so a comma-decimal device locale can't render "1,15" — iOS's // String(format:) is already locale-independent. Pure separator fix, no behavior change. - val pct = fmt(ratio, 2) - // Evidence: the two strain loads the ratio is built from, 1 dp each. - val evidence = "7d ${fmt(acute, 1)} / 28d ${fmt(chronic, 1)}" + val evidence = Evidence.TrainingLoad(acute, chronic) return when { ratio < 0.8 -> Signal( - key = "acwr", label = "Training load", - detail = "ramping down (acute:chronic $pct) - room to build", flag = Flag.WATCH, + key = "acwr", labelRes = R.string.today_readiness_signal_training_load, + detailRes = R.string.today_readiness_load_ramping_down, flag = Flag.WATCH, evidence = evidence, ) ratio < 1.3 -> Signal( - key = "acwr", label = "Training load", - detail = "in the sweet spot (acute:chronic $pct)", flag = Flag.GOOD, + key = "acwr", labelRes = R.string.today_readiness_signal_training_load, + detailRes = R.string.today_readiness_load_sweet_spot, flag = Flag.GOOD, evidence = evidence, ) ratio < 1.5 -> Signal( - key = "acwr", label = "Training load", - detail = "building fast (acute:chronic $pct) - watch fatigue", flag = Flag.WATCH, + key = "acwr", labelRes = R.string.today_readiness_signal_training_load, + detailRes = R.string.today_readiness_load_building_fast, flag = Flag.WATCH, evidence = evidence, ) else -> Signal( - key = "acwr", label = "Training load", - detail = "spiking (acute:chronic $pct) - higher injury risk", flag = Flag.BAD, + key = "acwr", labelRes = R.string.today_readiness_signal_training_load, + detailRes = R.string.today_readiness_load_spiking, flag = Flag.BAD, evidence = evidence, ) } @@ -379,11 +387,11 @@ object ReadinessEngine { // MARK: Synthesis - private fun synthesize(signals: List, hasHistory: Boolean): Triple { + private fun synthesize(signals: List, hasHistory: Boolean): Triple { if (!hasHistory || signals.isEmpty()) { return Triple( - Level.INSUFFICIENT, "Readiness", - "A few more nights of data and your readiness read will sharpen.", + Level.INSUFFICIENT, R.string.today_readiness_title, + R.string.today_readiness_more_nights, ) } val bad = signals.filter { it.flag == Flag.BAD } @@ -394,25 +402,25 @@ object ReadinessEngine { if (bad.size >= 2 || (recoveryDown && loadHigh)) { return Triple( - Level.RUNDOWN, "Run down", - "Several signals are down at once. Treat today as recovery - easy movement, real sleep tonight.", + Level.RUNDOWN, R.string.today_readiness_run_down, + R.string.today_readiness_run_down_summary, ) } if (recoveryDown || loadHigh || bad.size >= 1) { return Triple( - Level.STRAINED, "Strained", - "One of your signals is flagging. You can train, but keep it controlled and bank the recovery.", + Level.STRAINED, R.string.today_readiness_strained, + R.string.today_readiness_strained_summary, ) } if (good.size >= 2 && watch.isEmpty()) { return Triple( - Level.PRIMED, "Primed", - "Your signals are aligned and your load is supported. A harder session is well backed today.", + Level.PRIMED, R.string.today_readiness_primed, + R.string.today_readiness_primed_summary, ) } return Triple( - Level.BALANCED, "Balanced", - "Nothing's flagging. Train to feel - your body's holding steady.", + Level.BALANCED, R.string.today_readiness_balanced, + R.string.today_readiness_balanced_summary, ) } diff --git a/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt b/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt index e47733b9da..ee923dc318 100644 --- a/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt +++ b/android/app/src/main/java/com/noop/analytics/RecoveryDrivers.kt @@ -1,7 +1,6 @@ package com.noop.analytics import kotlin.math.abs -import kotlin.math.roundToInt // RecoveryDrivers.kt - the USER-FACING "What shaped it" breakdown for the Charge (recovery) score. // @@ -22,23 +21,57 @@ import kotlin.math.roundToInt // the exact rows. No em-dashes, no PII (values + baselines are the user's own, never logged here). /** - * One driver row behind the Charge (recovery) score, in the SHARED CONTRACT shape the iOS/macOS and - * Android dashboards both render. Field names are byte-identical across platforms. + * One semantic driver row behind the Charge (recovery) score. Presentation layers map its enums and + * measurements to localized copy and locale-aware formatting. * - * @property label short signal name, e.g. "Resting HR". + * @property label stable semantic identity of the signal. * @property deltaPoints signed contribution to the 0-100 Charge score versus this signal sitting at * the personal baseline (positive = lifted Charge, negative = pulled it down). A real marginal * sensitivity, never a fabricated apportionment. - * @property valueText the night's value, formatted with its unit, e.g. "58 bpm". - * @property baselineText the personal baseline the value was scored against, e.g. "61 bpm baseline". - * @property verdict short plain-English read, e.g. "below baseline, supporting recovery". + * @property value the night's numeric value in [unit]. + * @property baseline the personal baseline the value was scored against, or null when the value is + * already relative to its reference (skin temperature) or uses a fixed centre (sleep quality). + * @property unit semantic measurement unit shared by [value] and [baseline]. + * @property verdict semantic interpretation for presentation by the UI layer. */ +enum class ChargeDriverLabel { + HEART_RATE_VARIABILITY, + RESTING_HEART_RATE, + SLEEP_QUALITY, + RESPIRATORY_RATE, + SKIN_TEMPERATURE, +} + +enum class ChargeDriverUnit { + MILLISECONDS, + BEATS_PER_MINUTE, + PERCENT, + BREATHS_PER_MINUTE, + CELSIUS_DEVIATION, +} + +enum class ChargeDriverVerdict { + ABOVE_BASELINE_SUPPORTING, + BELOW_BASELINE_SUPPORTING, + ABOVE_BASELINE_LIMITING, + BELOW_BASELINE_LIMITING, + AT_BASELINE, + HRV_SATURATION_LIMITING, + STRONG_NIGHT_SUPPORTING, + BELOW_GOOD_NIGHT_LIMITING, + TYPICAL_NIGHT, + NEAR_BASELINE, + WARMER_THAN_BASELINE_LIMITING, + COOLER_THAN_BASELINE_LIMITING, +} + data class ChargeDriver( - val label: String, + val label: ChargeDriverLabel, val deltaPoints: Int, - val valueText: String, - val baselineText: String, - val verdict: String, + val value: Double, + val baseline: Double?, + val unit: ChargeDriverUnit, + val verdict: ChargeDriverVerdict, ) object RecoveryDrivers { @@ -106,13 +139,13 @@ object RecoveryDrivers { // One row per present term, appended in the SAME order the iOS twin uses (HRV, resting HR, Sleep, // respiration, skin temp), then sorted biggest-mover-first so the row that explains the most sits on - // top. Labels / value text / verdicts are byte-identical to the Swift canonical. + // top. The semantic cases retain the same distinctions as the Swift canonical. val drivers = ArrayList() // HRV (dominant driver; always present once the score exists). Neutral = HRV at the baseline mean. drivers.add( ChargeDriver( - label = "Heart rate variability", + label = ChargeDriverLabel.HEART_RATE_VARIABILITY, deltaPoints = points( RecoveryScorer.recovery( hrv = hrvBaseline.baseline, rhr = rhr, resp = resp, @@ -120,8 +153,9 @@ object RecoveryDrivers { respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ), - valueText = "${hrv.roundToInt()} ms", - baselineText = "${hrvBaseline.baseline.roundToInt()} ms baseline", + value = hrv, + baseline = hrvBaseline.baseline, + unit = ChargeDriverUnit.MILLISECONDS, verdict = hrvVerdict( value = hrv, baseline = hrvBaseline.baseline, @@ -133,7 +167,7 @@ object RecoveryDrivers { if (rhrBaseline != null) { drivers.add( ChargeDriver( - label = "Resting heart rate", + label = ChargeDriverLabel.RESTING_HEART_RATE, deltaPoints = points( RecoveryScorer.recovery( hrv = hrv, rhr = rhrBaseline.baseline, resp = resp, @@ -141,8 +175,9 @@ object RecoveryDrivers { respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ), - valueText = "${rhr.roundToInt()} bpm", - baselineText = "${rhrBaseline.baseline.roundToInt()} bpm baseline", + value = rhr, + baseline = rhrBaseline.baseline, + unit = ChargeDriverUnit.BEATS_PER_MINUTE, verdict = rhrVerdict(value = rhr, baseline = rhrBaseline.baseline), ), ) @@ -151,7 +186,7 @@ object RecoveryDrivers { if (sleepPerf != null) { drivers.add( ChargeDriver( - label = "Sleep quality", + label = ChargeDriverLabel.SLEEP_QUALITY, deltaPoints = points( RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, @@ -160,8 +195,9 @@ object RecoveryDrivers { skinTempDev = skinTempDev, ), ), - valueText = "${(sleepPerf * 100.0).roundToInt()}%", - baselineText = "", // centred on a fixed "good night", not a learned baseline + value = sleepPerf * 100.0, + baseline = null, + unit = ChargeDriverUnit.PERCENT, verdict = sleepVerdict(sleepPerf), ), ) @@ -170,7 +206,7 @@ object RecoveryDrivers { if (resp != null && respBaseline != null) { drivers.add( ChargeDriver( - label = "Respiratory rate", + label = ChargeDriverLabel.RESPIRATORY_RATE, deltaPoints = points( RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = respBaseline.baseline, @@ -178,8 +214,9 @@ object RecoveryDrivers { respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = skinTempDev, ), ), - valueText = String.format(java.util.Locale.US, "%.1f br/min", resp), - baselineText = String.format(java.util.Locale.US, "%.1f br/min baseline", respBaseline.baseline), + value = resp, + baseline = respBaseline.baseline, + unit = ChargeDriverUnit.BREATHS_PER_MINUTE, verdict = respVerdict(value = resp, baseline = respBaseline.baseline), ), ) @@ -189,7 +226,7 @@ object RecoveryDrivers { if (skinTempDev != null) { drivers.add( ChargeDriver( - label = "Skin temperature", + label = ChargeDriverLabel.SKIN_TEMPERATURE, deltaPoints = points( RecoveryScorer.recovery( hrv = hrv, rhr = rhr, resp = resp, @@ -197,8 +234,9 @@ object RecoveryDrivers { respBaseline = respBaseline, sleepPerf = sleepPerf, skinTempDev = 0.0, ), ), - valueText = String.format(java.util.Locale.US, "%+.1f C vs baseline", skinTempDev), - baselineText = "", // a deviation already; the reference is the personal baseline (0) + value = skinTempDev, + baseline = null, + unit = ChargeDriverUnit.CELSIUS_DEVIATION, verdict = skinTempVerdict(skinTempDev), ), ) @@ -217,37 +255,40 @@ object RecoveryDrivers { * same low-HRV + low-RHR pattern is also reported for non-functional overreaching, which is the opposite * of benign. Byte-for-byte the same strings as the iOS twin. */ - private fun hrvVerdict(value: Double, baseline: Double, saturationDetected: Boolean): String = when { - value > baseline -> "above baseline, supporting recovery" + private fun hrvVerdict( + value: Double, + baseline: Double, + saturationDetected: Boolean, + ): ChargeDriverVerdict = when { + value > baseline -> ChargeDriverVerdict.ABOVE_BASELINE_SUPPORTING value < baseline -> if (saturationDetected) { - "below baseline, limiting recovery, though low resting HR suggests this may be " + - "parasympathetic saturation rather than fatigue" + ChargeDriverVerdict.HRV_SATURATION_LIMITING } else { - "below baseline, limiting recovery" + ChargeDriverVerdict.BELOW_BASELINE_LIMITING } - else -> "at baseline" + else -> ChargeDriverVerdict.AT_BASELINE } /** Resting-HR verdict (lower is better). Mirrors Swift `rhrVerdict` exactly. */ - private fun rhrVerdict(value: Double, baseline: Double): String = when { - value < baseline -> "below baseline, supporting recovery" - value > baseline -> "above baseline, limiting recovery" - else -> "at baseline" + private fun rhrVerdict(value: Double, baseline: Double): ChargeDriverVerdict = when { + value < baseline -> ChargeDriverVerdict.BELOW_BASELINE_SUPPORTING + value > baseline -> ChargeDriverVerdict.ABOVE_BASELINE_LIMITING + else -> ChargeDriverVerdict.AT_BASELINE } /** Respiration verdict (lower is better). Mirrors Swift `respVerdict` exactly. */ - private fun respVerdict(value: Double, baseline: Double): String = when { - value < baseline -> "below baseline, supporting recovery" - value > baseline -> "above baseline, limiting recovery" - else -> "at baseline" + private fun respVerdict(value: Double, baseline: Double): ChargeDriverVerdict = when { + value < baseline -> ChargeDriverVerdict.BELOW_BASELINE_SUPPORTING + value > baseline -> ChargeDriverVerdict.ABOVE_BASELINE_LIMITING + else -> ChargeDriverVerdict.AT_BASELINE } /** Rest-quality verdict (higher is better), centred on sleepPerfCenter. Mirrors Swift `sleepVerdict`. */ - private fun sleepVerdict(sleepPerf: Double): String = when { - sleepPerf > RecoveryScorer.sleepPerfCenter -> "a strong night, supporting recovery" - sleepPerf < RecoveryScorer.sleepPerfCenter -> "below a good night, limiting recovery" - else -> "a typical night" + private fun sleepVerdict(sleepPerf: Double): ChargeDriverVerdict = when { + sleepPerf > RecoveryScorer.sleepPerfCenter -> ChargeDriverVerdict.STRONG_NIGHT_SUPPORTING + sleepPerf < RecoveryScorer.sleepPerfCenter -> ChargeDriverVerdict.BELOW_GOOD_NIGHT_LIMITING + else -> ChargeDriverVerdict.TYPICAL_NIGHT } /** Half-width (C) of the "typical" skin-temp band; matches Swift skinTempTypicalBandC. */ @@ -257,9 +298,9 @@ object RecoveryDrivers { * Skin-temp verdict (symmetric): a drift within the typical band reads neutral, beyond it limits * recovery, warmer or cooler. Mirrors the Swift skinTempVerdict exactly. */ - private fun skinTempVerdict(dev: Double): String = when { - abs(dev) <= SKIN_TEMP_TYPICAL_BAND_C -> "near baseline" - dev > 0.0 -> "warmer than baseline, limiting recovery" - else -> "cooler than baseline, limiting recovery" + private fun skinTempVerdict(dev: Double): ChargeDriverVerdict = when { + abs(dev) <= SKIN_TEMP_TYPICAL_BAND_C -> ChargeDriverVerdict.NEAR_BASELINE + dev > 0.0 -> ChargeDriverVerdict.WARMER_THAN_BASELINE_LIMITING + else -> ChargeDriverVerdict.COOLER_THAN_BASELINE_LIMITING } } diff --git a/android/app/src/main/java/com/noop/analytics/StepsEstimateEngine.kt b/android/app/src/main/java/com/noop/analytics/StepsEstimateEngine.kt index b1f9b44396..20713eddcc 100644 --- a/android/app/src/main/java/com/noop/analytics/StepsEstimateEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/StepsEstimateEngine.kt @@ -50,10 +50,7 @@ object StepsEstimateEngine { * from the engine's 0–1 confidence by fixed thresholds so iOS + Android show the SAME word. A manual `k` * is reported as [HIGH] (the user asserted it). Mirror of Swift `ConfidenceTier`. (#760/#792) */ - enum class ConfidenceTier(val word: String) { - LOW("low confidence"), - MEDIUM("medium confidence"), - HIGH("high confidence"); + enum class ConfidenceTier { LOW, MEDIUM, HIGH; companion object { /** 0–1 confidence → tier. < 0.34 low, < 0.67 medium, else high. Byte-identical to Swift. */ @@ -71,11 +68,28 @@ object StepsEstimateEngine { * Mirror of Swift `CalibrationStatus`. */ sealed interface CalibrationStatus { + sealed interface Headline { + object Manual : Headline + data class Calibrated(val sampleDays: Int) : Headline + object ConnectPhoneSteps : Headline + data class NeedMoreDays(val remaining: Int) : Headline + } + + sealed interface Detail { + data class Manual(val coefficient: Double) : Detail + data class Calibrated( + val coefficient: Double, + val sampleDays: Int, + val confidenceTier: ConfidenceTier, + ) : Detail + data class Calibrating(val have: Int, val need: Int) : Detail + } + /** True when an estimate can be produced right now (manual or a usable auto-fit). */ val canEstimate: Boolean /** A short, honest one-liner for the tile/Settings. US-neutral, no em-dashes. */ - val headline: String + val headline: Headline /** The confidence tier for the steps estimate. [Calibrated] maps its 0–1 confidence; [Manual] is * HIGH (asserted by the user); [NeedsMoreDays] is LOW. Mirror of Swift `confidenceTier`. (#760/#792) */ @@ -88,52 +102,51 @@ object StepsEstimateEngine { /** A denser status line (numbers, vs the plain-English [headline]): confidence tier plus, when * calibrated/manual, `k` and the day count, so a frozen or dashed steps tile self-explains. Mirror of * Swift `detail`. (#760/#792) */ - val detail: String + val detail: Detail /** A manual `k` is in force. [sampleDays] = auto-fit days that exist alongside it (informational). */ data class Manual(val coefficient: Double, val sampleDays: Int) : CalibrationStatus { override val canEstimate: Boolean get() = true - override val headline: String get() = "Calibrated by hand" + override val headline: Headline get() = Headline.Manual override val confidenceTier: ConfidenceTier get() = ConfidenceTier.HIGH override val coefficientOrNull: Double get() = coefficient - override val detail: String get() = "manual k=${formatK(coefficient)}" + override val detail: Detail get() = Detail.Manual(coefficient) } /** Enough overlapping days fit an auto coefficient. Carries the fit and its 0–1 confidence. */ data class Calibrated(val coefficient: Double, val sampleDays: Int, val confidence: Double) : CalibrationStatus { override val canEstimate: Boolean get() = true - override val headline: String - get() = "Estimated from $sampleDays day${if (sampleDays == 1) "" else "s"} your phone also counted" + override val headline: Headline get() = Headline.Calibrated(sampleDays) override val confidenceTier: ConfidenceTier get() = ConfidenceTier.from(confidence) override val coefficientOrNull: Double get() = coefficient - override val detail: String - get() = "k=${formatK(coefficient)} from $sampleDays day${if (sampleDays == 1) "" else "s"}, ${ConfidenceTier.from(confidence).word}" + override val detail: Detail + get() = Detail.Calibrated(coefficient, sampleDays, ConfidenceTier.from(confidence)) } /** Not yet calibrated: [have] overlapping phone-counted days out of [need]. */ data class NeedsMoreDays(val have: Int, val need: Int) : CalibrationStatus { override val canEstimate: Boolean get() = false - override val headline: String + override val headline: Headline get() { // #589 follow-up: have == 0 means NO day yet had BOTH strap motion and a phone-counted step // total. A WHOOP 4.0 always streams motion, so on a 4.0 this is really "no phone step source // connected" - the "Need N more days" countdown would never advance (no day will ever gain a // phone step count). Say what actually unblocks it instead of a frozen counter. if (have == 0) { - return "Connect your phone's step count to estimate steps" + return Headline.ConnectPhoneSteps } val more = maxOf(0, need - have) - return "Need $more more day${if (more == 1) "" else "s"} where your phone also counted steps" + return Headline.NeedMoreDays(more) } override val confidenceTier: ConfidenceTier get() = ConfidenceTier.LOW override val coefficientOrNull: Double? get() = null - override val detail: String get() = "calibrating: ${minOf(have, need)}/$need days" + override val detail: Detail get() = Detail.Calibrating(minOf(have, need), need) } } /** Format the steps coefficient `k` to one decimal place for the status line (US-neutral, locale-free so * iOS + Android match byte-for-byte). Mirror of Swift `formatK`. (#760/#792) */ - internal fun formatK(k: Double): String = String.format(java.util.Locale.US, "%.1f", k) + internal fun formatK(k: Double): String = String.format(java.util.Locale.getDefault(), "%.1f", k) /** * Classify the current calibration state from the same inputs [calibrate] sees, so the UI can explain diff --git a/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt b/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt index d1e1c9fcad..fc415dc10f 100644 --- a/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt +++ b/android/app/src/main/java/com/noop/ui/AutoWorkoutNudge.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -71,43 +72,30 @@ private const val AUTO_DETECT_SPORT = "Workout" /** Days of HR history the scan covers — matches the iOS `autoDetectCandidate(daysBack: 2)`. */ private const val AUTO_DETECT_DAYS_BACK = 2L -private val autoNudgeTimeFmt: DateTimeFormatter = - // HH:mm in the user's locale/timezone — mirrors the iOS card's short-time DateFormatter. - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - .withLocale(Locale.getDefault()).withZone(ZoneId.systemDefault()) - -private val autoNudgeDateFmt: DateTimeFormatter = - // Localized MEDIUM date ("23 Jun 2026") for a bout older than yesterday. Mirrors the iOS card. - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(Locale.getDefault()).withZone(ZoneId.systemDefault()) - -private fun hhmm(epochSec: Long): String = autoNudgeTimeFmt.format(Instant.ofEpochSecond(epochSec)) +private sealed interface AutoWorkoutDay { + data object Today : AutoWorkoutDay + data object Yesterday : AutoWorkoutDay + data class OnDate(val epochSec: Long) : AutoWorkoutDay +} -/** A relative LOCAL-day prefix for the prompt (#719): "" when the bout started today, "yesterday " when - * it was yesterday, else "on ". The card showed HH:mm only, so a late-night bout could read as - * today; this anchors it to the local day instead of UTC. Mirrors iOS `AutoWorkoutCard.dayLabel`. */ -private fun dayLabel(epochSec: Long): String { +private fun autoWorkoutDay(epochSec: Long): AutoWorkoutDay { val zone = ZoneId.systemDefault() val day = Instant.ofEpochSecond(epochSec).atZone(zone).toLocalDate() val today = LocalDate.now(zone) return when (day) { - today -> "" - today.minusDays(1) -> "yesterday " - else -> "on ${autoNudgeDateFmt.format(Instant.ofEpochSecond(epochSec))} " + today -> AutoWorkoutDay.Today + today.minusDays(1) -> AutoWorkoutDay.Yesterday + else -> AutoWorkoutDay.OnDate(epochSec) } } -/** "Looks like a workout [yesterday ]around 14:05–14:32 (avg HR 148, 27 min). Save it?" Mirrors iOS. */ -private fun promptText(w: AutoWorkoutDetector.DetectedWorkout): String = - "Looks like a workout ${dayLabel(w.startSec)}around ${hhmm(w.startSec)} - ${hhmm(w.endSec)} " + - "(avg HR ${w.avgBpm}, ${w.durationMin} min). Save it?" - @Composable fun AutoWorkoutNudgeCard( viewModel: AppViewModel, days: List, ) { val context = LocalContext.current + val locale = LocalConfiguration.current.locales[0] // Read once — SharedPreferences isn't reactive; when off, the whole feature is invisible + inert. val enabled = remember { NoopPrefs.autoDetectWorkouts(context) } if (!enabled) return @@ -128,6 +116,24 @@ fun AutoWorkoutNudgeCard( val w = candidate if (handledThisSession || w == null) return + val timeFormatter = remember(locale) { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).withZone(ZoneId.systemDefault()) + } + val startTime = timeFormatter.format(Instant.ofEpochSecond(w.startSec)) + val endTime = timeFormatter.format(Instant.ofEpochSecond(w.endSec)) + val prompt = when (val day = autoWorkoutDay(w.startSec)) { + AutoWorkoutDay.Today -> uiString(R.string.today_auto_workout_prompt_today, startTime, endTime, w.avgBpm, w.durationMin) + AutoWorkoutDay.Yesterday -> uiString(R.string.today_auto_workout_prompt_yesterday, startTime, endTime, w.avgBpm, w.durationMin) + is AutoWorkoutDay.OnDate -> { + val dateFormatter = remember(locale) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).withZone(ZoneId.systemDefault()) + } + uiString( + R.string.today_auto_workout_prompt_date, + dateFormatter.format(Instant.ofEpochSecond(day.epochSec)), startTime, endTime, w.avgBpm, w.durationMin, + ) + } + } NoopCard(tint = Palette.accent) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -163,7 +169,7 @@ fun AutoWorkoutNudgeCard( } } Text( - promptText(w), + prompt, style = NoopType.footnote, color = Palette.textSecondary, ) diff --git a/android/app/src/main/java/com/noop/ui/CoupledScreen.kt b/android/app/src/main/java/com/noop/ui/CoupledScreen.kt index d01bc4e7ac..600a943a45 100644 --- a/android/app/src/main/java/com/noop/ui/CoupledScreen.kt +++ b/android/app/src/main/java/com/noop/ui/CoupledScreen.kt @@ -364,8 +364,12 @@ private fun HeroCard( // prior score (#543/#779, the SAME caption Today uses), or the calibrating progress while // the baseline seeds. Nothing when today's own score is showing. if (isCarrying && carriedDay != null) { + val caption = carriedCaption(carriedDay.day, today = todayKey) Text( - carriedCaption(carriedDay.day, today = todayKey), + when (caption) { + is DisplayText.Resource -> uiString(caption.id, *caption.args.toTypedArray()) + is DisplayText.Dynamic -> caption.value + }, style = NoopType.footnote, color = Palette.textTertiary, ) @@ -406,8 +410,8 @@ private fun HeroCentre(recovery: Double?, readinessLevel: ReadinessEngine.Level) Text(COUPLED_NO_DATA, style = NoopType.headline, color = Palette.textSecondary) } Text(uiString(R.string.l10n_coupled_screen_recovery_b668d988), style = NoopType.overline, color = sampled) - val word = readinessWord(readinessLevel) - if (word != null) ReadinessPill(word = word, level = readinessLevel) + val wordRes = readinessWord(readinessLevel) + if (wordRes != null) ReadinessPill(word = uiString(wordRes), level = readinessLevel) } } diff --git a/android/app/src/main/java/com/noop/ui/DashboardCards.kt b/android/app/src/main/java/com/noop/ui/DashboardCards.kt index bec1e0270b..b461f4cc8b 100644 --- a/android/app/src/main/java/com/noop/ui/DashboardCards.kt +++ b/android/app/src/main/java/com/noop/ui/DashboardCards.kt @@ -1,6 +1,7 @@ package com.noop.ui import android.content.Context +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Air import androidx.compose.material.icons.filled.AutoAwesome @@ -17,6 +18,7 @@ import androidx.compose.material.icons.filled.Thermostat import androidx.compose.material.icons.filled.WaterDrop import androidx.compose.ui.graphics.vector.ImageVector import org.json.JSONArray +import com.noop.R // MARK: - "Your cards" customisable dashboard (WHOOP "My Dashboard") — Kotlin twin of DashboardCards.swift // @@ -39,30 +41,30 @@ import org.json.JSONArray */ enum class DashboardCard( val raw: String, - val title: String, - val subtitle: String, + @StringRes val titleRes: Int, + @StringRes val subtitleRes: Int, val unit: String, val icon: ImageVector, ) { - HRV("hrv", "HRV", "Heart-rate variability", "ms", Icons.Filled.MonitorHeart), - RESTING_HR("restingHr", "Resting HR", "Resting heart rate", "bpm", Icons.Filled.Favorite), - RESPIRATORY("respiratory", "Respiratory", "Breaths per minute", "rpm", Icons.Filled.Air), - STEPS("steps", "Steps", "Today", "", Icons.AutoMirrored.Filled.DirectionsWalk), - STRESS("stress", "Stress", "Autonomic load", "", Icons.Filled.Bolt), - FITNESS_AGE("fitnessAge", "Fitness Age", "Updated weekly", "yrs", Icons.AutoMirrored.Filled.DirectionsRun), - VO2MAX("vo2max", "VO₂ Max", "Estimated, updated weekly", "", Icons.Filled.Air), - VITALITY("vitality", "Vitality", "Wellness score", "", Icons.Filled.AutoAwesome), - BLOOD_OXYGEN("bloodOxygen", "Blood Oxygen", "Blood oxygen", "", Icons.Filled.WaterDrop), - SKIN_TEMP("skinTemp", "Skin Temp", "Skin temperature", "", Icons.Filled.Thermostat), - SLEEP("sleep", "Sleep", "Last night", "", Icons.Filled.Bedtime), - CALORIES("calories", "Calories", "Active energy", "kcal", Icons.Filled.LocalFireDepartment), - HYDRATION("hydration", "Hydration", "Today's fluid", "", Icons.Filled.LocalDrink), + HRV("hrv", R.string.today_card_hrv, R.string.today_card_hrv_subtitle, "ms", Icons.Filled.MonitorHeart), + RESTING_HR("restingHr", R.string.today_card_resting_hr, R.string.today_card_resting_hr_subtitle, "bpm", Icons.Filled.Favorite), + RESPIRATORY("respiratory", R.string.today_card_respiratory, R.string.today_card_respiratory_subtitle, "rpm", Icons.Filled.Air), + STEPS("steps", R.string.today_card_steps, R.string.today_card_steps_subtitle, "", Icons.AutoMirrored.Filled.DirectionsWalk), + STRESS("stress", R.string.today_card_stress, R.string.today_card_stress_subtitle, "", Icons.Filled.Bolt), + FITNESS_AGE("fitnessAge", R.string.today_card_fitness_age, R.string.today_card_fitness_age_subtitle, "yrs", Icons.AutoMirrored.Filled.DirectionsRun), + VO2MAX("vo2max", R.string.today_card_vo2max, R.string.today_card_vo2max_subtitle, "", Icons.Filled.Air), + VITALITY("vitality", R.string.today_card_vitality, R.string.today_card_vitality_subtitle, "", Icons.Filled.AutoAwesome), + BLOOD_OXYGEN("bloodOxygen", R.string.today_card_blood_oxygen, R.string.today_card_blood_oxygen_subtitle, "", Icons.Filled.WaterDrop), + SKIN_TEMP("skinTemp", R.string.today_card_skin_temp, R.string.today_card_skin_temp_subtitle, "", Icons.Filled.Thermostat), + SLEEP("sleep", R.string.today_card_sleep, R.string.today_card_sleep_subtitle, "", Icons.Filled.Bedtime), + CALORIES("calories", R.string.today_card_calories, R.string.today_card_calories_subtitle, "kcal", Icons.Filled.LocalFireDepartment), + HYDRATION("hydration", R.string.today_card_hydration, R.string.today_card_hydration_subtitle, "", Icons.Filled.LocalDrink), // Optional, default-OFF (task #43): a tap-through to the Coupled view (the WHOOP-style day read). Unlike // every other card it carries NO metric value of its own, it is a navigation row that opens the full // CoupledScreen. It is NOT in [defaultSelection], so a fresh install never shows it until the user adds // it via CUSTOMISE. Mirrors iOS DashboardCard.coupled (raw "coupled", byte-identical across OS). - COUPLED("coupled", "Coupled view", "Recovery, strain and sleep in one glance", "", Icons.Filled.Hexagon); + COUPLED("coupled", R.string.today_card_coupled, R.string.today_card_coupled_subtitle, "", Icons.Filled.Hexagon); companion object { fun fromRaw(raw: String?): DashboardCard? = entries.firstOrNull { it.raw == raw } diff --git a/android/app/src/main/java/com/noop/ui/HealthScreen.kt b/android/app/src/main/java/com/noop/ui/HealthScreen.kt index c01ad72671..bdd8d5623d 100644 --- a/android/app/src/main/java/com/noop/ui/HealthScreen.kt +++ b/android/app/src/main/java/com/noop/ui/HealthScreen.kt @@ -1998,8 +1998,12 @@ private fun VitalReadingsTable(rows: List) { style = NoopType.bodyNumber, color = Palette.textPrimary, ) + val sourceLabel = when (val source = row.source) { + is DisplayText.Resource -> uiString(source.id, *source.args.toTypedArray()) + is DisplayText.Dynamic -> source.value + } Text( - row.source, + sourceLabel, style = NoopType.footnote, color = provenanceLabelTint(row.source), textAlign = TextAlign.End, diff --git a/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt b/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt index afa1898b90..70246fe14a 100644 --- a/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt +++ b/android/app/src/main/java/com/noop/ui/HealthVitalDetailLogic.kt @@ -40,7 +40,7 @@ internal fun mergeReadings(vararg stores: Map): List Icons.Filled.Bedtime } - val label: String + @get:StringRes + val labelRes: Int get() = when (this) { - CHARGE -> "Charge" - EFFORT -> "Effort" - REST -> "Rest" + CHARGE -> R.string.today_metric_charge + EFFORT -> R.string.today_metric_effort + REST -> R.string.today_metric_rest } /** A representative sample fraction (0–1) for the section's illustrative gauge — a @@ -307,7 +309,7 @@ private fun LegendDot(section: ScoreSection) { .clip(CircleShape) .background(section.accent), ) - Text(section.label, style = NoopType.caption, color = Palette.textSecondary) + Text(stringResource(section.labelRes), style = NoopType.caption, color = Palette.textSecondary) } } @@ -361,7 +363,7 @@ private fun ScoreCard( modifier = Modifier.size(16.dp), ) Text( - section.label.uppercase(), + stringResource(section.labelRes).uppercase(), style = NoopType.overline, color = section.accent, ) @@ -411,7 +413,7 @@ private fun SampleRing(section: ScoreSection) { lineWidth = 8.dp, ) Text( - section.label.uppercase(), + stringResource(section.labelRes).uppercase(), style = NoopType.overline, color = Palette.textTertiary, ) diff --git a/android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt b/android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt index bbca9cd53d..86c1f7db08 100644 --- a/android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt +++ b/android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt @@ -311,9 +311,11 @@ private fun CurrentFitCard(profile: ProfileStore, matchedDays: Int) { // #589: a concrete countdown instead of a vague "a few days". Headline comes straight from // the engine's NeedsMoreDays state so the wording matches the Today steps tile + the Swift card. Text( - StepsEstimateEngine.CalibrationStatus - .NeedsMoreDays(have = matchedDays, need = StepsEstimateEngine.MIN_CALIBRATION_DAYS) - .headline, + stepsCalibrationHeadline( + StepsEstimateEngine.CalibrationStatus + .NeedsMoreDays(have = matchedDays, need = StepsEstimateEngine.MIN_CALIBRATION_DAYS) + .headline, + ), style = NoopType.bodyNumber, color = Palette.accent, ) @@ -327,6 +329,17 @@ private fun CurrentFitCard(profile: ProfileStore, matchedDays: Int) { } } +@Composable +private fun stepsCalibrationHeadline(headline: StepsEstimateEngine.CalibrationStatus.Headline): String = when (headline) { + StepsEstimateEngine.CalibrationStatus.Headline.Manual -> uiString(R.string.today_steps_headline_manual) + is StepsEstimateEngine.CalibrationStatus.Headline.Calibrated -> + uiString(R.string.today_steps_headline_calibrated, headline.sampleDays) + StepsEstimateEngine.CalibrationStatus.Headline.ConnectPhoneSteps -> + uiString(R.string.today_steps_headline_connect_phone) + is StepsEstimateEngine.CalibrationStatus.Headline.NeedMoreDays -> + uiString(R.string.today_steps_headline_more_days, headline.remaining) +} + /** The accuracy table: recent days with BOTH an estimate and a phone count, side by side, so the user * can SEE how close the estimate runs. Empty until enough both-have days exist. */ @Composable diff --git a/android/app/src/main/java/com/noop/ui/TodayLayoutPrefs.kt b/android/app/src/main/java/com/noop/ui/TodayLayoutPrefs.kt index 51280356d0..58549819ff 100644 --- a/android/app/src/main/java/com/noop/ui/TodayLayoutPrefs.kt +++ b/android/app/src/main/java/com/noop/ui/TodayLayoutPrefs.kt @@ -1,6 +1,8 @@ package com.noop.ui import android.content.Context +import androidx.annotation.StringRes +import com.noop.R // MARK: - Reorderable Today sections (#today-layout) // @@ -20,22 +22,22 @@ import android.content.Context * One reorderable Today section. The [raw] is the stable persisted identifier — keep it byte-identical to * the macOS `TodaySection` enum so a backup/restore reads the same layout on either OS. */ -enum class TodaySection(val raw: String, val title: String) { - HERO("hero", "Charge / Effort / Rest"), - LIVE_SESSION("liveSession", "Start session"), - SYNTHESIS("synthesis", "Synthesis"), - KEY_METRICS("keyMetrics", "Key Metrics"), - WORKOUTS("workouts", "Workouts"), - HEART_RATE("heartRate", "Heart Rate"), - RECOVERY_VITALS("recoveryVitals", "Recovery Vitals"), - YOUR_CARDS("yourCards", "Your Cards"), - MENSTRUAL_CYCLE("menstrualCycle", "Menstrual Cycle"), - JOURNAL("journal", "Journal"), +enum class TodaySection(val raw: String, @StringRes val titleRes: Int) { + HERO("hero", R.string.today_section_hero), + LIVE_SESSION("liveSession", R.string.today_section_live_session), + SYNTHESIS("synthesis", R.string.today_section_synthesis), + KEY_METRICS("keyMetrics", R.string.today_section_key_metrics), + WORKOUTS("workouts", R.string.today_section_workouts), + HEART_RATE("heartRate", R.string.today_section_heart_rate), + RECOVERY_VITALS("recoveryVitals", R.string.today_section_recovery_vitals), + YOUR_CARDS("yourCards", R.string.today_section_your_cards), + MENSTRUAL_CYCLE("menstrualCycle", R.string.today_section_menstrual_cycle), + JOURNAL("journal", R.string.today_section_journal), /** Cards hosted from the Trends / Sleep tabs (#today-hosted-cards). Renders the [HostedCardPrefs] * selection in order; empty (and effectively invisible) until the user adds a card in Customise. * Appended LAST so [decodeOrder]'s back-fill lands it predictably for existing saved orders. */ - ADDED_CARDS("addedCards", "Added Cards"); + ADDED_CARDS("addedCards", R.string.today_section_added_cards); companion object { fun fromRaw(raw: String?): TodaySection? = entries.firstOrNull { it.raw == raw } diff --git a/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt b/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt index 1c2441b386..1c21cc4466 100644 --- a/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt +++ b/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt @@ -1,5 +1,7 @@ package com.noop.ui +import androidx.annotation.StringRes +import com.noop.R import com.noop.data.AppleDaily import com.noop.data.WorkoutRow @@ -14,11 +16,13 @@ import com.noop.data.WorkoutRow * windows coincide with Today, which reads fine - both mean "everything so far". Only offered on the * CURRENT day: a past day has no "now", so it always shows the full calendar day, exactly as before. */ -internal enum class HrWindow(val label: String, val hours: Int) { +internal enum class HrWindow(@StringRes val labelRes: Int, val hours: Int) { // Declaration order IS the pill order: Today (the whole loaded day) anchors the wide end, then // strictly most -> least hours. TODAY stays ordinal 0 so the rememberSaveable default is the full day. - TODAY("Today", 0), - H24("24h", 24), H12("12h", 12), H6("6h", 6), H3("3h", 3), H1("1h", 1); + TODAY(R.string.today_hr_window_today, 0), + H24(R.string.today_hr_window_24h, 24), H12(R.string.today_hr_window_12h, 12), + H6(R.string.today_hr_window_6h, 6), H3(R.string.today_hr_window_3h, 3), + H1(R.string.today_hr_window_1h, 1); /** Earliest bucket timestamp (unix seconds) this window renders, anchored at `now`. TODAY = no * narrowing. Anchoring at the wall clock (not the newest banked bucket) keeps the card honest: a @@ -54,7 +58,12 @@ internal fun lastWorkoutsFeed(rows: List): List = internal const val METRICS_COLLAPSED_CAP = 6 /** The Weight tile's display string and an honest caption. */ -internal data class WeightTileText(val value: String, val caption: String?) +internal enum class WeightCaption(@StringRes val labelRes: Int) { + LATEST(R.string.today_weight_latest), + FROM_PROFILE(R.string.today_weight_from_profile), +} + +internal data class WeightTileText(val value: String, val caption: WeightCaption?) /** * The newest body weight across the two Apple-side sources (apple-health + health-connect), or null @@ -89,7 +98,7 @@ internal fun stepsForDay(apple: List, healthConnect: List FusionSource.WHOOP_IMPORT } -internal fun provenanceBadgeLabel(owner: FusionSource?): String? = when (owner) { - FusionSource.NOOP_COMPUTED -> "On-device" - FusionSource.WHOOP_IMPORT -> "Whoop" - FusionSource.APPLE_HEALTH -> "Apple Health" - FusionSource.HEALTH_CONNECT -> "Health Connect" - FusionSource.XIAOMI_BAND -> "Mi Band" - FusionSource.NUTRITION_CSV -> "Nutrition" - FusionSource.LOCAL_CACHE -> "Cached" +internal sealed interface DisplayText { + data class Resource(@StringRes val id: Int, val args: List = emptyList()) : DisplayText + data class Dynamic(val value: String) : DisplayText +} + +internal fun provenanceBadgeLabel(owner: FusionSource?): DisplayText? = when (owner) { + FusionSource.NOOP_COMPUTED -> DisplayText.Resource(R.string.today_source_on_device) + FusionSource.WHOOP_IMPORT -> DisplayText.Resource(R.string.today_source_whoop) + FusionSource.APPLE_HEALTH -> DisplayText.Resource(R.string.today_source_apple_health) + FusionSource.HEALTH_CONNECT -> DisplayText.Resource(R.string.today_source_health_connect) + FusionSource.XIAOMI_BAND -> DisplayText.Resource(R.string.today_source_mi_band) + FusionSource.NUTRITION_CSV -> DisplayText.Resource(R.string.today_source_nutrition) + FusionSource.LOCAL_CACHE -> DisplayText.Resource(R.string.today_source_cached) null -> null } @@ -49,20 +56,22 @@ internal fun provenanceBadgeLabel(owner: FusionSource?): String? = when (owner) internal fun provenanceDisplayLabel( rawSource: String, deviceId: String = WhoopRepository.WHOOP_SOURCE, -): String { - if (rawSource.endsWith("-noop")) return "On-device" - if (rawSource == deviceId || rawSource == WhoopRepository.WHOOP_SOURCE) return "Whoop" - if (rawSource == WhoopRepository.APPLE_HEALTH_SOURCE) return "Apple Health" +): DisplayText { + if (rawSource.endsWith("-noop")) return DisplayText.Resource(R.string.today_source_on_device) + if (rawSource == deviceId || rawSource == WhoopRepository.WHOOP_SOURCE) return DisplayText.Resource(R.string.today_source_whoop) + if (rawSource == WhoopRepository.APPLE_HEALTH_SOURCE) return DisplayText.Resource(R.string.today_source_apple_health) // Fall back to the FusionSource display name for any other known source; else the raw id verbatim. - return FusionSource.entries.firstOrNull { it.id == rawSource }?.displayName ?: rawSource + return FusionSource.entries.firstOrNull { it.id == rawSource } + ?.let { source -> provenanceBadgeLabel(source) } + ?: DisplayText.Dynamic(rawSource) } /** Today uses the audience-facing sensor name for Apple Health scores, matching the Swift Today lane. */ internal fun todayProvenanceChipLabel( rawSource: String, deviceId: String = WhoopRepository.WHOOP_SOURCE, -): String = if (rawSource == WhoopRepository.APPLE_HEALTH_SOURCE) { - "Apple Watch" +): DisplayText = if (rawSource == WhoopRepository.APPLE_HEALTH_SOURCE) { + DisplayText.Resource(R.string.today_source_apple_watch) } else { provenanceDisplayLabel(rawSource, deviceId) } @@ -71,24 +80,25 @@ internal fun todayProvenanceChipLabel( internal data class ScoreInputProvider(val sourceId: String, val brand: String? = null) /** Provider-facing hero wording. Registered brands cover live devices; stable import ids cover imports. */ -internal fun todayScoreProviderLabel(provider: ScoreInputProvider): String { +internal fun todayScoreProviderLabel(provider: ScoreInputProvider): DisplayText { val source = provider.sourceId.lowercase() return when (source) { - WhoopRepository.APPLE_HEALTH_SOURCE -> "Apple Watch" - WhoopRepository.HEALTH_CONNECT_SOURCE -> "Health Connect" - "oura-import", "oura-api" -> "Oura" - "fitbit-import" -> "Fitbit" - "garmin-import" -> "Garmin" - "xiaomi-band" -> "Mi Band" - WhoopRepository.ACTIVITY_FILE_SOURCE -> "Workout files" + WhoopRepository.APPLE_HEALTH_SOURCE -> DisplayText.Resource(R.string.today_source_apple_watch) + WhoopRepository.HEALTH_CONNECT_SOURCE -> DisplayText.Resource(R.string.today_source_health_connect) + "oura-import", "oura-api" -> DisplayText.Resource(R.string.today_source_oura) + "fitbit-import" -> DisplayText.Resource(R.string.today_source_fitbit) + "garmin-import" -> DisplayText.Resource(R.string.today_source_garmin) + "xiaomi-band" -> DisplayText.Resource(R.string.today_source_mi_band) + WhoopRepository.ACTIVITY_FILE_SOURCE -> DisplayText.Resource(R.string.today_source_workout_files) else -> { val brand = provider.brand?.trim().orEmpty() when { - brand.equals("WHOOP", ignoreCase = true) -> "Whoop" - brand.isNotEmpty() -> brand - source == WhoopRepository.WHOOP_SOURCE -> "Whoop" - else -> FusionSource.entries.firstOrNull { it.id == provider.sourceId }?.displayName - ?: provider.sourceId + brand.equals(FusionSource.WHOOP_IMPORT.displayName, ignoreCase = true) -> DisplayText.Resource(R.string.today_source_whoop) + brand.isNotEmpty() -> DisplayText.Dynamic(brand) + source == WhoopRepository.WHOOP_SOURCE -> DisplayText.Resource(R.string.today_source_whoop) + else -> FusionSource.entries.firstOrNull { it.id == provider.sourceId } + ?.let { provenanceBadgeLabel(it) } + ?: DisplayText.Dynamic(provider.sourceId) } } } @@ -101,13 +111,13 @@ internal fun todayScoreProviderLabel(provider: ScoreInputProvider): String { */ internal fun heroSourceLabel( providers: List, -): String? { - val labels = LinkedHashSet() +): List { + val labels = LinkedHashSet() for (provider in providers) { labels.add(todayScoreProviderLabel(provider)) if (labels.size == 2) break } - return labels.takeIf { it.isNotEmpty() }?.joinToString(" + ") + return labels.toList() } /** @@ -120,7 +130,7 @@ internal fun scoreHeroSourceLabel( providerByMetric: Map, carriedRecoveryProvider: ScoreInputProvider?, usesCarriedRecovery: Boolean, -): String? { +): List { val recoveryProvider = providerByMetric["recovery"] ?: if (usesCarriedRecovery) carriedRecoveryProvider else null return heroSourceLabel( @@ -143,10 +153,10 @@ internal fun todayPullToSyncEnabled( /** The tint for a per-metric provenance badge, keyed on the resolved LABEL, gold for Whoop, cyan for * Apple Health, the positive status hue for on-device (and anything else). Matches the Data Sources * footer + the Swift `provenanceTint` so the same source reads the same colour on Today. */ -internal fun provenanceLabelTint(label: String): Color = when (label) { - "Whoop" -> Palette.accent - "Apple Health" -> Palette.metricCyan - "Health Connect" -> Palette.metricPurple +internal fun provenanceLabelTint(label: DisplayText): Color = when ((label as? DisplayText.Resource)?.id) { + R.string.today_source_whoop -> Palette.accent + R.string.today_source_apple_health -> Palette.metricCyan + R.string.today_source_health_connect -> Palette.metricPurple else -> Palette.statusPositive } @@ -156,11 +166,12 @@ internal fun provenanceLabelTint(label: String): Color = when (label) { * returns null (the hero then shows no word, matching the old card hiding itself). Byte-identical twin of * the Swift TodayView.readinessWord. */ -internal fun readinessWord(level: ReadinessEngine.Level): String? = when (level) { - ReadinessEngine.Level.PRIMED -> "Push" - ReadinessEngine.Level.BALANCED -> "Maintain" - ReadinessEngine.Level.STRAINED -> "Rest" - ReadinessEngine.Level.RUNDOWN -> "Rest" +@StringRes +internal fun readinessWord(level: ReadinessEngine.Level): Int? = when (level) { + ReadinessEngine.Level.PRIMED -> R.string.today_readiness_push + ReadinessEngine.Level.BALANCED -> R.string.today_readiness_maintain + ReadinessEngine.Level.STRAINED -> R.string.today_readiness_rest + ReadinessEngine.Level.RUNDOWN -> R.string.today_readiness_rest ReadinessEngine.Level.INSUFFICIENT -> null } @@ -171,12 +182,12 @@ internal fun readinessWord(level: ReadinessEngine.Level): String? = when (level) * hasHealthConnect source - Health Connect is named for what it is, never folded under "Apple Watch" * (issue #176). */ -internal fun syncedFromSummary(hasWhoop: Boolean, hasApple: Boolean, hasHealthConnect: Boolean = false, hasXiaomi: Boolean): String { +internal fun syncedFromSummary(hasWhoop: Boolean, hasApple: Boolean, hasHealthConnect: Boolean = false, hasXiaomi: Boolean): List { val names = buildList { - if (hasWhoop) add("WHOOP") - if (hasApple) add("Apple Watch") - if (hasHealthConnect) add("Health Connect") - if (hasXiaomi) add("Mi Band") + if (hasWhoop) add(DisplayText.Resource(R.string.today_source_whoop)) + if (hasApple) add(DisplayText.Resource(R.string.today_source_apple_watch)) + if (hasHealthConnect) add(DisplayText.Resource(R.string.today_source_health_connect)) + if (hasXiaomi) add(DisplayText.Resource(R.string.today_source_mi_band)) } - return if (names.isEmpty()) "No sources yet" else "Synced from: " + names.joinToString(", ") + return names } diff --git a/android/app/src/main/java/com/noop/ui/TodayScoring.kt b/android/app/src/main/java/com/noop/ui/TodayScoring.kt index 942b59d3ea..2dd8588a9f 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -1,5 +1,6 @@ package com.noop.ui +import com.noop.R import com.noop.analytics.BaselineState import com.noop.analytics.Baselines import com.noop.analytics.ChargeDriver @@ -137,7 +138,7 @@ internal data class LastCharge(val value: Double, val caption: String) * the key and falls back to the raw key so the caption is never empty. Mirrors iOS lastChargeDateFmt. */ internal fun lastChargeDateLabel(dayKey: String): String = runCatching { - LocalDate.parse(dayKey).format(DateTimeFormatter.ofPattern("d MMM", Locale.US)) + LocalDate.parse(dayKey).format(DateTimeFormatter.ofPattern("d MMM", Locale.getDefault())) }.getOrDefault(dayKey) /** Carry-over recency cap (#779): the "Last night" framing only holds when the carried scored day is @@ -173,10 +174,12 @@ internal fun freshRestScore( * freshness cap it reads "Last night · "; once the carried day is older than the cap (#779) it reads * "Latest sleep · " so a weeks-old import is never surfaced as "Last night". Shared by every carried * recovery read-out so the prior-day provenance reads identically. Mirrors iOS carriedCaption. */ -internal fun carriedCaption(priorDayKey: String, today: String = LocalDate.now().toString()): String { - val prefix = if (isCarryStale(priorDayKey, today)) "Latest sleep" else "Last night" - return "$prefix · ${lastChargeDateLabel(priorDayKey)}" -} +internal fun carriedCaption(priorDayKey: String, today: String = LocalDate.now().toString()): DisplayText = + DisplayText.Resource( + if (isCarryStale(priorDayKey, today)) R.string.score_state_title_latest_sleep + else R.string.score_state_title_last_night, + listOf(lastChargeDateLabel(priorDayKey)), + ) // Explainability layer, COMPONENTS 2, 3, 4 (spec: 2026-06-20-sleep-guidance-explainability.md) // @@ -210,13 +213,20 @@ sealed class ScoreState { /** No data for today at all, strap not worn / not connected / not synced. Shows NO number. */ object NeedsStrap : ScoreState() - /** The status title shown in the tile's state slot. VERBATIM, mirror Swift exactly. */ - val title: String + val titleRes: Int? + get() = when (this) { + is Scored -> null + is Calibrating -> R.string.score_state_title_calibrating + is CarriedLastNight -> if (stale) R.string.score_state_title_latest_sleep else R.string.score_state_title_last_night + NeedsStrap -> R.string.score_state_title_needs_strap + } + + val detailRes: Int? get() = when (this) { - is Scored -> "" - is Calibrating -> "Calibrating" - is CarriedLastNight -> if (stale) "Latest sleep · $dateLabel" else "Last night · $dateLabel" - NeedsStrap -> "Needs the strap" + is Scored -> null + is Calibrating -> R.plurals.score_state_detail_calibrating + is CarriedLastNight -> if (stale) R.string.score_state_detail_carried_stale else R.string.score_state_detail_carried_fresh + NeedsStrap -> R.string.score_state_detail_needs_strap } /** @@ -241,31 +251,13 @@ sealed class ScoreState { return Instant.ofEpochSecond(epochSeconds) .atZone(ZoneId.systemDefault()) .toLocalDate() - .format(DateTimeFormatter.ofPattern("d MMM", Locale.US)) + .format(DateTimeFormatter.ofPattern("d MMM", Locale.getDefault())) } - fun calibrationRestartCause(recalibratedOn: String?): String? { - if (recalibratedOn.isNullOrEmpty()) return null - return "Restarted when you recalibrated on $recalibratedOn — no need to tap it again." - } + internal fun calibrationRestartCause(recalibratedOn: String?): DisplayText? = recalibratedOn + ?.takeIf { it.isNotEmpty() } + ?.let { DisplayText.Resource(R.string.today_calibration_restart_cause, listOf(it)) } } - - /** The one-line plain-English what-to-do. VERBATIM, mirror Swift exactly. The night(s) plural in - * the calibrating copy follows [nightsRemaining]. */ - val detail: String - get() = when (this) { - is Scored -> "" - is Calibrating -> { - val nights = if (nightsRemaining == 1) "night" else "nights" - "Building your baseline. About $nightsRemaining more $nights until your scores are personal." - } - is CarriedLastNight -> - // A fresh post-rollover carry tells you tonight's score is on its way; a stale carry (an - // older import, #779) instead explains the number is from that earlier session, not today. - if (stale) "This is your last scored session. Wear the strap overnight for a fresh score." - else "Tonight's lands after you sleep with the strap on." - NeedsStrap -> "No data for today. Was your strap worn and connected overnight?" - } } /** @@ -321,24 +313,21 @@ sealed class RecordingState { * the link genuinely IS up, so claiming "Strap not connected" would be false. */ object ConnectedNoData : RecordingState() - /** The chip's status word. VERBATIM, mirror Swift exactly. */ - val title: String + val titleRes: Int get() = when (this) { - Recording -> "Recording" - is LastSynced -> "Last synced ${minutesAgo}m ago" - NotRecording -> "Not recording" - HistoryExperimental -> "Connected" - ConnectedNoData -> "Connected" + Recording -> R.string.recording_chip_title_recording + is LastSynced -> R.string.recording_chip_title_last_synced + NotRecording -> R.string.recording_chip_title_not_recording + HistoryExperimental, ConnectedNoData -> R.string.recording_chip_title_connected } - /** The chip's one-line detail. VERBATIM, mirror Swift exactly. */ - val detail: String + val detailRes: Int get() = when (this) { - Recording -> "Your strap is connected and saving data." - is LastSynced -> "Reconnect to pull the latest." - NotRecording -> "Strap not connected. Tap to connect." - HistoryExperimental -> "History sync is experimental on 5.0." - ConnectedNoData -> "No live heart rate or synced history yet this session." + Recording -> R.string.recording_chip_detail_recording + is LastSynced -> R.string.recording_chip_detail_last_synced + NotRecording -> R.string.recording_chip_detail_not_recording + HistoryExperimental -> R.string.recording_chip_detail_history_experimental + ConnectedNoData -> R.string.recording_chip_detail_connected_no_data } /** Chip hue: live recording reads positive (gold/green dot), a stale-but-recent sync reads neutral, @@ -464,14 +453,14 @@ internal fun restStageLowConfidence(d: DailyMetric?): Boolean { } /** Short "it's coming, not broken" caption for an unscored tile on today only. */ -internal fun buildingHint(metric: KeyMetric, isToday: Boolean): String? { +internal fun buildingHint(metric: KeyMetric, isToday: Boolean): Int? { if (!isToday) return null return when (metric) { - KeyMetric.REST -> "Building, wear it tonight" - KeyMetric.EFFORT -> "Building, moves as you do" - KeyMetric.CHARGE -> "Building, wear it tonight" - KeyMetric.BLOOD_OXYGEN -> "Building, wear it tonight" - KeyMetric.STEPS -> "Building, moves as you do" + KeyMetric.REST -> R.string.today_building_wear_tonight + KeyMetric.EFFORT -> R.string.today_building_moves_with_you + KeyMetric.CHARGE -> R.string.today_building_wear_tonight + KeyMetric.BLOOD_OXYGEN -> R.string.today_building_wear_tonight + KeyMetric.STEPS -> R.string.today_building_moves_with_you else -> null } } diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 0fde876f7f..953afe6f4f 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -146,6 +146,9 @@ import com.noop.R import com.noop.analytics.Baselines import com.noop.analytics.BatteryEstimator import com.noop.analytics.ChargeDriver +import com.noop.analytics.ChargeDriverLabel +import com.noop.analytics.ChargeDriverUnit +import com.noop.analytics.ChargeDriverVerdict import com.noop.analytics.HydrationGoal import com.noop.analytics.HydrationStore import com.noop.analytics.ReadinessEngine @@ -172,6 +175,19 @@ import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt +@Composable +private fun DisplayText.localized(): String = when (this) { + is DisplayText.Resource -> uiString(id, *args.toTypedArray()) + is DisplayText.Dynamic -> value +} + +@Composable +private fun List.localizedSourceList(): String { + val labels = ArrayList(size) + for (label in this) labels += label.localized() + return labels.joinToString(uiString(R.string.today_source_joiner)) +} + /** * Control Center, the home dashboard. A recovery ring + plain-English synthesis * hero, an illness banner when the watch fires, and a tile grid of the day's key @@ -372,17 +388,17 @@ fun TodayScreen( // Keep the explicit calendar date visible alongside Today/Yesterday so the logical-day remap stays // honest, between midnight and 04:00 "Today" still points at the prior calendar date, and showing // that date makes it obvious which day's row is on screen (#144). - val dayLabel = remember(selectedDayOffset, selectedDay, selectedDayKey) { + val dayLabel = run { // Date the label by the row ACTUALLY on screen, not the raw logical date. `selectedDayKey` already // follows the resolver's `today?.day` at offset 0, so when the resolver surfaces yesterday's // complete row (today not scored yet) the date now reads that row's day, instead of stamping // "Today · " over yesterday's values, which disagreed with the Intelligence History row for // the same data (#434). iOS/Mac already label by the shown row's day; this brings Android to parity. val keyDate = runCatching { LocalDate.parse(selectedDayKey) }.getOrNull() ?: selectedDay - val date = keyDate.format(DateTimeFormatter.ofPattern("EEE, d MMM", Locale.US)) + val date = keyDate.format(DateTimeFormatter.ofPattern("EEE, d MMM", Locale.getDefault())) when (selectedDayOffset) { - 0 -> "Today · $date" - 1 -> "Yesterday · $date" + 0 -> uiString(R.string.today_day_with_date, date) + 1 -> uiString(R.string.today_yesterday_with_date, date) else -> date } } @@ -551,10 +567,14 @@ fun TodayScreen( BatteryEstimator.estimate(samples, rated)?.let { est -> val hours = est.hoursRemaining if (!hours.isFinite() || hours <= 0.0) null - else if (hours < 48) "~${hours.roundToInt()}h left" + else if (hours < 48) context.resources.getQuantityString( + R.plurals.today_strap_hours_left, hours.roundToInt(), hours.roundToInt(), + ) else { val daysLeft = (hours / 24).roundToInt() - "~$daysLeft day${if (daysLeft == 1) "" else "s"} left" + context.resources.getQuantityString( + R.plurals.today_strap_days_left, daysLeft, daysLeft, + ) } } }.getOrNull() @@ -731,12 +751,13 @@ fun TodayScreen( if (newestKey <= previousKey) return@LaunchedEffect // recompute churn, not new history val added = days.map { it.day }.toSet().count { it > previousKey } if (added <= 0) return@LaunchedEffect - val daysWord = if (added == 1) "day" else "days" store.post( UpdateItem( kind = UpdateKind.READING, title = uiString(R.string.l10n_today_screen_new_data_added_e59345b4), - message = "$added new $daysWord of history is ready in Trends.", + message = context.resources.getQuantityString( + R.plurals.today_new_history_ready, added, added, + ), deepLink = "trends", ), ) @@ -995,9 +1016,10 @@ fun TodayScreen( } // Carry-over Charge for TODAY, the prior scored row's recovery + its "Last night · " caption. // Derived from lastScoredRecoveryDay so Charge and every other recovery tile carry the SAME prior day. - val lastScoredCharge: LastCharge? = remember(lastScoredRecoveryDay) { + val carriedChargeCaption = lastScoredRecoveryDay?.let { carriedCaption(it.day, carryOverTodayKey).localized() } + val lastScoredCharge: LastCharge? = remember(lastScoredRecoveryDay, carriedChargeCaption) { lastScoredRecoveryDay?.let { prior -> - prior.recovery?.let { LastCharge(it, carriedCaption(prior.day, carryOverTodayKey)) } + prior.recovery?.let { LastCharge(it, carriedChargeCaption.orEmpty()) } } } var carriedRecoveryProvider by remember { mutableStateOf(null) } @@ -1033,13 +1055,14 @@ fun TodayScreen( // One honest card-level badge, matching LiquidTodayView: identical winners collapse to one label; // mixed winners show at most two sources in Charge / Effort / Rest order so the pill stays compact. - val heroSourceLabel = remember(providerByMetric, carriedRecoveryProvider, displayMetric?.recovery, lastScoredCharge) { + val heroSourceParts = remember(providerByMetric, carriedRecoveryProvider, displayMetric?.recovery, lastScoredCharge) { scoreHeroSourceLabel( providerByMetric = providerByMetric, carriedRecoveryProvider = carriedRecoveryProvider, usesCarriedRecovery = displayMetric?.recovery == null && lastScoredCharge != null, ) } + val heroSourceLabel = heroSourceParts.takeIf { it.isNotEmpty() }?.localizedSourceList() // 14-day trailing calendar window ending on the phone's actual local day. // Old imports stay in history, but they do not fill the Today trend tiles. @@ -1175,11 +1198,11 @@ fun TodayScreen( // the Updates inbox is relocated into the "+" quick-actions sheet (AppRoot), so the feature stays one // tap away without sitting in the Today header. Staggered in as the first section (index 0). val dayTitle = when (selectedDayOffset) { - 0 -> "Today" - 1 -> "Yesterday" + 0 -> uiString(R.string.today_day_today) + 1 -> uiString(R.string.today_day_yesterday) else -> { val keyDate = runCatching { LocalDate.parse(selectedDayKey) }.getOrNull() ?: selectedDay - keyDate.format(DateTimeFormatter.ofPattern("EEEE", Locale.US)) + keyDate.format(DateTimeFormatter.ofPattern("EEEE", Locale.getDefault())) } } // Human date line under the title — "Friday, 3 July" (weekday + day + month), NOT a numeric date. @@ -1187,7 +1210,7 @@ fun TodayScreen( // the iOS `dateLine` (EEEE, d MMMM). Mirrors iOS's date-under-title block. val humanDate = run { val keyDate = runCatching { LocalDate.parse(selectedDayKey) }.getOrNull() ?: selectedDay - keyDate.format(DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.US)) + keyDate.format(DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.getDefault())) } // #486: header + wordmark + Arrange fold into ONE compact top cluster. Previously the decorative // "N O O P" wordmark and the pinned "Arrange" affordance were each their own full-width list item, @@ -1304,7 +1327,7 @@ fun TodayScreen( ScoreState.recalibrationDay( NoopPrefs.of(context).getLong(Baselines.hrvBaselineEpochKey, 0L), ), - ), + )?.localized(), ) if (updateStore != null) { TodayCardDismissButton( @@ -1312,8 +1335,8 @@ fun TodayScreen( onClick = { dismissTodayCard( CARD_CALIBRATING, - "Building your baseline", - "Charge, Effort and Rest become personal after a few nights of wear.", + context.getString(R.string.today_update_building_baseline_title), + context.getString(R.string.today_update_building_baseline_message), ) }, ) @@ -1324,10 +1347,7 @@ fun TodayScreen( Box(modifier = Modifier.fillMaxWidth()) { DataPendingNote( title = uiString(R.string.l10n_today_screen_live_now_your_scores_are_building_cb05a4e8), - body = "Your live heart rate is working from the strap, and recovery, strain " + - "and sleep build from it over your next few nights of wear, sharpening as it " + - "learns your baseline. Want your full history instantly? Import your WHOOP " + - "export in Data Sources and it backfills in about a minute.", + body = uiString(R.string.today_pending_scores_body), ) // The × is only meaningful for today's card (a past day's note isn't dismissed). if (selectedDayOffset == 0 && updateStore != null) { @@ -1336,8 +1356,8 @@ fun TodayScreen( onClick = { dismissTodayCard( CARD_SCORES_BUILDING, - "Live now. Your scores are building.", - "Charge, Effort and Rest build over your next few nights of wear.", + context.getString(R.string.today_update_scores_building_title), + context.getString(R.string.today_update_scores_building_message), ) }, ) @@ -1497,7 +1517,7 @@ fun TodayScreen( ) { Row(verticalAlignment = Alignment.Top) { Box(modifier = Modifier.weight(1f)) { - SectionHeader("Key Metrics", overline = dayLabel, trailing = trendWindowLabel(keyMetricsWindowDays)) + SectionHeader(uiString(R.string.today_section_key_metrics), overline = dayLabel, trailing = trendWindowLabel(keyMetricsWindowDays)) } TodayEditAction( onClick = { showMetricsEditor = true }, @@ -1923,14 +1943,14 @@ private fun LiveSessionEntryCard(onOpen: () -> Unit) { } val teal = Palette.metricCyan val title = when { - running -> "Session running" - summaryWaiting -> "Session ended" - else -> "Start session" + running -> uiString(R.string.today_live_session_running) + summaryWaiting -> uiString(R.string.today_live_session_ended) + else -> uiString(R.string.today_live_session_start) } val detail = when { - running -> "Guarding — silence means you're on track." - summaryWaiting -> "See the summary of your last session." - else -> "Strap-guided effort session. It only buzzes when you drift off today's band." + running -> uiString(R.string.today_live_session_guarding) + summaryWaiting -> uiString(R.string.today_live_session_summary) + else -> uiString(R.string.today_live_session_explanation) } // liquidPress on the whole tappable card (same interactionSource on clickable + press), matching the @@ -1962,7 +1982,7 @@ private fun LiveSessionEntryCard(onOpen: () -> Unit) { horizontalArrangement = Arrangement.spacedBy(Metrics.space8), ) { Text(title, style = NoopType.headline, color = Palette.textPrimary) - StatePill("BETA", tone = StrandTone.Accent, showsDot = false) + StatePill(uiString(R.string.today_beta), tone = StrandTone.Accent, showsDot = false) } Text(detail, style = NoopType.footnote, color = Palette.textTertiary) } @@ -2044,7 +2064,8 @@ private fun ScoreInfoButton( modifier: Modifier = Modifier, compact: Boolean = false, ) { - val label = section?.let { "How ${it.label} is calculated" } ?: "How this score is calculated" + val label = section?.let { uiString(R.string.today_how_score_named, uiString(it.labelRes)) } + ?: uiString(R.string.today_how_score_generic) val button = if (compact) 24.dp else Metrics.iconButton val glyph = if (compact) 16.dp else Metrics.iconSmall IconButton(onClick = onClick, modifier = modifier.size(button)) { @@ -2182,7 +2203,7 @@ private fun LiquidTodayHeader( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClickLabel = "Change day", + onClickLabel = uiString(R.string.today_action_change_day), onClick = { showPicker = true }, ) .semantics { contentDescription = uiString(R.string.l10n_today_screen_daytitle_humandate_tap_to_pick_a_7e12ce96, dayTitle, humanDate) }, @@ -2308,7 +2329,8 @@ private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: Stri @Composable private fun LiquidBatteryRing(batteryPct: Double?, onClick: () -> Unit) { val interaction = remember { MutableInteractionSource() } - val label = batteryPct?.let { "Strap battery ${it.roundToInt()} percent" } ?: "Strap battery" + val label = batteryPct?.let { uiString(R.string.today_strap_battery_percent, it.roundToInt()) } + ?: uiString(R.string.today_strap_battery) Box( modifier = Modifier .size(HeaderClusterControl) @@ -2565,7 +2587,7 @@ private fun ScoreHeroRow( diameter = ring, animated = animated, showsValue = strain != null, - format = { if (effortScale == EffortScale.WHOOP) String.format(Locale.US, "%.1f", it) else it.toInt().toString() }, + format = { if (effortScale == EffortScale.WHOOP) String.format(Locale.getDefault(), "%.1f", it) else it.toInt().toString() }, ) if (strain == null) RingNoData() } @@ -2654,7 +2676,7 @@ private fun HeroRingColumn( .clickable( interactionSource = ringInteraction, indication = null, - onClickLabel = "See what shaped your ${domain.label}", + onClickLabel = uiString(R.string.today_action_see_what_shaped, domain.label), onClick = onRingTap, ), ) { ring() } @@ -2796,20 +2818,22 @@ private fun SynthesisHeroCard( if (days.isEmpty()) ReadinessEngine.Level.INSUFFICIENT else ReadinessEngine.evaluate(days, today = logicalDayKeyNow()).level } - readinessWord(readinessLevel)?.let { word -> - ReadinessHeroPill(word = word, level = readinessLevel, onTap = onOpenReadiness) + readinessWord(readinessLevel)?.let { wordRes -> + ReadinessHeroPill(word = uiString(wordRes), level = readinessLevel, onTap = onOpenReadiness) } // SOLID only when TODAY's own row carries a settled recovery, a carried prior-day read is // honestly still CALIBRATING for today, matching the iOS pill (keyed on displayDay.recovery). val todayRecovery = day?.recovery StatePill( - title = if (todayRecovery != null) "SOLID" else "CALIBRATING", + title = if (todayRecovery != null) uiString(R.string.today_score_solid) + else uiString(R.string.score_state_title_calibrating).uppercase(), tone = if (todayRecovery != null) StrandTone.Accent else StrandTone.Neutral, ) } // S4: the Synthesis card collapses to a one-liner that expands on tap. The headline (the status) is // the SAME in both states, only the detail body and chrome fold, never the read (#506). - val status = if (recoveryCalibration != null) "Calibrating" else synthesisWord(recovery) + val status = if (recoveryCalibration != null) uiString(R.string.score_state_title_calibrating) + else synthesisWord(recovery) val detail = if (recoveryCalibration != null) { // #612: if the baseline aged out silently — connected, but no new night for > staleDays — say WHY // it's calibrating instead of only "learning your baseline". `stale` is always > staleDays (14). @@ -2819,11 +2843,14 @@ private fun SynthesisHeroCard( } else { // Comma (not the old em-dash) to match the Swift canonical synthesis copy VERBATIM // (TodayView "Learning your baseline, N of M nights.") and the no-em-dash standing rule. - "Learning your baseline, $recoveryCalibration of ${Baselines.minNightsSeed} nights." + uiString(R.string.today_synthesis_learning_baseline, recoveryCalibration, Baselines.minNightsSeed) } } else if (carriedDay != null) { // Carried prior-day read, summarise that day + stamp it so it isn't passed off as today's. - synthesisDetail(carriedDay) + " ${carriedCaption(carriedDay.day)}." + uiString( + R.string.today_synthesis_carried_detail, + synthesisDetail(carriedDay), carriedCaption(carriedDay.day).localized(), + ) } else { synthesisDetail(day) } @@ -2835,13 +2862,13 @@ private fun SynthesisHeroCard( .clickable( interactionSource = expandedInteraction, indication = null, - onClickLabel = "Collapse", + onClickLabel = uiString(R.string.today_action_collapse), onClick = onToggleSynthesis, ), ) { InsightCard( modifier = Modifier.fillMaxWidth(), - category = "Synthesis", + category = uiString(R.string.today_section_synthesis), status = status, detail = detail, // The SYNTHESIS headline reads WHITE (textPrimary), not the recovery/charge colour, the @@ -2862,7 +2889,7 @@ private fun SynthesisHeroCard( .clickable( interactionSource = collapsedInteraction, indication = null, - onClickLabel = "Expand for the full read", + onClickLabel = uiString(R.string.today_action_expand_full_read), onClick = onToggleSynthesis, ), ) { @@ -2902,7 +2929,7 @@ private fun ReadinessHeroPill(word: String, level: ReadinessEngine.Level, onTap: .clip(RoundedCornerShape(50)) .background(tone.copy(alpha = 0.12f)) .border(1.dp, tone.copy(alpha = 0.32f), RoundedCornerShape(50)) - .clickable(onClickLabel = "See your full readiness", onClick = onTap) + .clickable(onClickLabel = uiString(R.string.today_action_full_readiness), onClick = onTap) .padding(horizontal = 10.dp, vertical = 5.dp) .semantics { contentDescription = uiString(R.string.l10n_today_screen_readiness_word_749b3330, word) }, verticalAlignment = Alignment.CenterVertically, @@ -2944,7 +2971,7 @@ private fun RingEmptyOverlay( @Composable private fun RingNoData() { - Text(NO_DATA, style = NoopType.headline, color = Palette.textTertiary, maxLines = 1) + Text(uiString(R.string.today_no_data), style = NoopType.headline, color = Palette.textTertiary, maxLines = 1) } /** #898: the Rest ring's overlay when a Charge exists for the day but there's no scored sleep (the @@ -3004,10 +3031,10 @@ private fun HeroMetricRows(day: DailyMetric?, carriedDay: DailyMetric? = null, v verticalArrangement = Arrangement.spacedBy(Metrics.space12), ) { Row(verticalAlignment = Alignment.CenterVertically) { - Overline("Recovery vitals", modifier = Modifier.weight(1f)) + Overline(uiString(R.string.today_section_recovery_vitals), modifier = Modifier.weight(1f)) // iOS `lastNightLine` — today's own "Last night · " unless the shown vitals are a carry. Text( - if (carriedFromVitals) carriedCaption(vitalsDay!!.day) else heroVitalsLastNightLine(), + if (carriedFromVitals) carriedCaption(vitalsDay!!.day).localized() else heroVitalsLastNightLine(), style = NoopType.caption, color = Palette.textTertiary, ) @@ -3026,7 +3053,7 @@ private fun HeroMetricRows(day: DailyMetric?, carriedDay: DailyMetric? = null, v ) HeroVitalRow( label = uiString(R.string.l10n_today_screen_breaths_per_minute_2b197c54), - value = resp?.let { String.format(Locale.US, "%.1f rpm", it) } ?: NO_DATA, + value = resp?.let { String.format(Locale.getDefault(), "%.1f rpm", it) } ?: NO_DATA, tint = Palette.accent, fraction = resp?.let { (it / 24.0).coerceIn(0.0, 1.0) }, ) @@ -3035,9 +3062,10 @@ private fun HeroMetricRows(day: DailyMetric?, carriedDay: DailyMetric? = null, v } /** iOS `lastNightLine` — "Last night · " where is yesterday in "d MMM" form. */ +@Composable private fun heroVitalsLastNightLine(): String { val d = LocalDate.now().minusDays(1) - return "Last night · ${d.format(DateTimeFormatter.ofPattern("d MMM", Locale.US))}" + return uiString(R.string.today_last_night_date, d.format(DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()))) } /** One iOS `vitalRow`: a 26dp mini liquid VESSEL filled to [fraction] in [tint], the label (subhead, @@ -3045,10 +3073,11 @@ private fun heroVitalsLastNightLine(): String { @Composable private fun HeroVitalRow(label: String, value: String, tint: Color, fraction: Double?) { val hasValue = value != NO_DATA + val displayValue = localizedMetricValue(value) Row( modifier = Modifier .fillMaxWidth() - .semantics { contentDescription = uiString(R.string.l10n_today_screen_label_value_b781d590, label, value) }, + .semantics { contentDescription = uiString(R.string.l10n_today_screen_label_value_b781d590, label, displayValue) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Metrics.space12), ) { @@ -3060,7 +3089,7 @@ private fun HeroVitalRow(label: String, value: String, tint: Color, fraction: Do ) Text(label, style = NoopType.subhead, color = Palette.textSecondary, modifier = Modifier.weight(1f)) Text( - value, + displayValue, style = NoopType.number(15f), color = if (hasValue) Palette.textPrimary else Palette.textTertiary, ) @@ -3224,7 +3253,7 @@ private fun YourCardsSection( Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { // Header: "YOUR CARDS" overline + a right-aligned blue EDIT action (the WHOOP ✎ affordance). Row(verticalAlignment = Alignment.CenterVertically) { - Overline("Your cards", modifier = Modifier.weight(1f)) + Overline(uiString(R.string.today_section_your_cards), modifier = Modifier.weight(1f)) TodayEditAction( onClick = onCustomise, contentDescription = uiString(R.string.l10n_today_screen_customise_your_cards_2428d761), @@ -3298,12 +3327,13 @@ private fun YourCardsSection( * (not the sleep card, or no banked sleep). Twin of iOS `TodayView.sleepSourceSubtitle`; the source * mechanism differs per platform (Android keys on the day's session source, iOS on `importedSleep`), * exactly as the two Sleep-tab badges already do, so the label — not the wiring — is what stays in parity. */ +@Composable private fun sleepSourceSubtitle(card: DashboardCard, day: DailyMetric?): String? { if (card != DashboardCard.SLEEP) return null val d = day ?: return null if (d.totalSleepMin == null) return null val source = daySourceBadge(d.deviceId).first - return "$source · last night" + return uiString(R.string.today_sleep_source_last_night, source) } /** The `vital_detail/` key a metric/vital card opens, or null when the card has its OWN dedicated @@ -3473,15 +3503,15 @@ private fun dashboardCardValue( // `vitalsDay?.respRateBpm` is dropped on purpose (see the gauge site + Swift `lastRespDay`): // it had no age bound and showed a fortnight-old imported 15.6 as today's rate (#1331). A gap // past the window now reads NO_DATA — the truthful answer when nobody measured. - withUnit((day?.respRateBpm ?: respDay?.respRateBpm)?.let { String.format(Locale.US, "%.1f", it) } ?: NO_DATA) + withUnit((day?.respRateBpm ?: respDay?.respRateBpm)?.let { String.format(Locale.getDefault(), "%.1f", it) } ?: NO_DATA) DashboardCard.BLOOD_OXYGEN -> // PER-FIELD carry: the whole-row carries (vd) land on rows whose spo2Pct is null (the engine // writes spo2Pct = null on computed rows), so fall through to the last row that HAS one. // #103: when no calibrated spo2Pct exists, fall back to the spo2_candidate_82 strap estimate // (from metricSeries) when the experimental display toggle is ON. Labelled "estimate" in the // Health vitals screen; here it just fills the card so it's not blank. - (vd?.spo2Pct ?: spo2Day?.spo2Pct)?.let { String.format(Locale.US, "%.0f%%", it) } - ?: (vd?.day ?: day?.day)?.let { spo2CandidateByDay[it] }?.let { String.format(Locale.US, "%.0f%%", it) } + (vd?.spo2Pct ?: spo2Day?.spo2Pct)?.let { String.format(Locale.getDefault(), "%.0f%%", it) } + ?: (vd?.day ?: day?.day)?.let { spo2CandidateByDay[it] }?.let { String.format(Locale.getDefault(), "%.0f%%", it) } ?: NO_DATA DashboardCard.SKIN_TEMP -> { // #622: bimodal field — absolute °C (import) vs signed Δ°C vs baseline (live). @@ -3515,7 +3545,7 @@ private fun dashboardCardValue( // " / L" in litres to 1 dp, e.g. "1.2 / 3.2 L". Always shows a value (a fresh // day reads "0.0 / 3.2 L"), since the goal is always derivable from the profile. String.format( - Locale.US, "%.1f / %.1f L", + Locale.getDefault(), "%.1f / %.1f L", hydrationTotalMl / 1000.0, hydrationGoalMl / 1000.0, ) DashboardCard.COUPLED -> @@ -3544,6 +3574,7 @@ private fun DashboardCardRow( ) { // A real number renders white; a placeholder (No Data, or the Stress calibrating state) renders dimmed. val hasValue = value != NO_DATA && value != STRESS_CALIBRATING + val displayValue = localizedMetricValue(value) // iOS `cardLink` corner is 20 (a touch rounder than the app-wide 18dp card), with the SAME neutral // surfaceRaised fill + plain hairline the frosted neutral surface already draws. val rowShape = RoundedCornerShape(20.dp) @@ -3564,7 +3595,7 @@ private fun DashboardCardRow( } // iOS row padding: 14h / 11v (tighter than the old 13/11 icon-box row). .padding(horizontal = 14.dp, vertical = 11.dp) - .semantics { contentDescription = uiString(R.string.l10n_today_screen_card_title_value_e4bb76b3, card.title, value) }, + .semantics { contentDescription = uiString(R.string.l10n_today_screen_card_title_value_e4bb76b3, uiString(card.titleRes), displayValue) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -3583,14 +3614,14 @@ private fun DashboardCardRow( ) { // iOS: overline 11 / +1.0 tracking, textPrimary. Text( - card.title.uppercase(), + uiString(card.titleRes).uppercase(), style = NoopType.overline.copy(fontSize = 11.sp, letterSpacing = 1.0.sp), color = Palette.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - subtitleOverride ?: card.subtitle, + subtitleOverride ?: uiString(card.subtitleRes), style = NoopType.caption, color = Palette.textTertiary, maxLines = 1, @@ -3599,7 +3630,7 @@ private fun DashboardCardRow( } // iOS value = number(17), textPrimary. Text( - value, + displayValue, style = NoopType.number(17f), color = if (hasValue) Palette.textPrimary else Palette.textTertiary, maxLines = 1, @@ -3658,8 +3689,9 @@ private fun stepsCalibrationPrompt(context: Context, profileStore: ProfileStore) * calibration the estimate used. So a WHOOP 4.0 user can see WHY the number reads as it does (and why it may * look frozen at low confidence) right where they notice the "est." flag. Falls back to a bare "est." when no * coefficient is recorded yet. Mirrors iOS `stepsEstimateCaption`. */ +@Composable private fun stepsEstimateCaption(profileStore: ProfileStore): String { - if (profileStore.stepsCalibrationCoefficient <= 0.0) return "est." + if (profileStore.stepsCalibrationCoefficient <= 0.0) return uiString(R.string.today_steps_estimated_short) val status: StepsEstimateEngine.CalibrationStatus = if (profileStore.stepsCalibrationManual) { StepsEstimateEngine.CalibrationStatus.Manual( coefficient = profileStore.stepsCalibrationCoefficient, @@ -3672,14 +3704,33 @@ private fun stepsEstimateCaption(profileStore: ProfileStore): String { confidence = profileStore.stepsCalibrationConfidence, ) } - return "est. · ${status.detail}" + return uiString(R.string.today_steps_estimated_detail, stepsCalibrationDetail(status.detail)) +} + +@Composable +private fun stepsCalibrationDetail(detail: StepsEstimateEngine.CalibrationStatus.Detail): String = when (detail) { + is StepsEstimateEngine.CalibrationStatus.Detail.Manual -> + uiString(R.string.today_steps_manual_coefficient, String.format(Locale.getDefault(), "%.1f", detail.coefficient)) + is StepsEstimateEngine.CalibrationStatus.Detail.Calibrated -> { + val confidence = when (detail.confidenceTier) { + StepsEstimateEngine.ConfidenceTier.LOW -> uiString(R.string.today_steps_confidence_low) + StepsEstimateEngine.ConfidenceTier.MEDIUM -> uiString(R.string.today_steps_confidence_medium) + StepsEstimateEngine.ConfidenceTier.HIGH -> uiString(R.string.today_steps_confidence_high) + } + uiString( + R.string.today_steps_calibrated_detail, + String.format(Locale.getDefault(), "%.1f", detail.coefficient), detail.sampleDays, confidence, + ) + } + is StepsEstimateEngine.CalibrationStatus.Detail.Calibrating -> + uiString(R.string.today_steps_calibrating_detail, detail.have, detail.need) } /** Group-separated integer display from a Double (e.g. 12 345 steps), matching the Apple Health tiles. A * file-internal twin of the private [intString] so the dashboard rows format steps/calories identically. */ private fun intStringGrouped(v: Double): String { val n = v.roundToInt() - return if (kotlin.math.abs(n) >= 1000) String.format(Locale.US, "%,d", n) else "$n" + return if (kotlin.math.abs(n) >= 1000) String.format(Locale.getDefault(), "%,d", n) else "$n" } // MARK: - Shared Shown / Hidden editor rows @@ -3853,6 +3904,7 @@ private fun DashboardCardsEditorDialog( onDismiss: () -> Unit, onSave: (List) -> Unit, ) { + val titles = DashboardCard.entries.associateWith { uiString(it.titleRes) } val shown = remember { mutableStateListOf().apply { addAll(initial) } } val hidden = remember { mutableStateListOf().apply { @@ -3872,8 +3924,7 @@ private fun DashboardCardsEditorDialog( Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(uiString(R.string.l10n_today_screen_my_dashboard_a7a19e72), style = NoopType.title2, color = Palette.textPrimary) Text( - uiString(R.string.l10n_today_screen_choose_which_cards_show_on_today_f79942e1) + - "Cards with no value yet show a dash.", + uiString(R.string.today_dashboard_editor_description), style = NoopType.subhead, color = Palette.textSecondary, ) @@ -3882,7 +3933,7 @@ private fun DashboardCardsEditorDialog( EditableVisibilityRows( shown = shown, hidden = hidden, - itemTitle = { it.title }, + itemTitle = { titles.getValue(it) }, ) Row(verticalAlignment = Alignment.CenterVertically) { @@ -4201,7 +4252,7 @@ private fun TodayLayoutEditorDialog( EditableVisibilityRows( shown = shown, hidden = hidden, - itemTitle = { it.title }, + itemTitle = { uiString(it.titleRes) }, ) // #today-hosted-cards: hand-off to the editor that chooses WHICH Trends/Sleep cards the @@ -4299,7 +4350,7 @@ internal fun ChargeBreakdownSheet( .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .clickable( - onClickLabel = "How Charge is calculated", + onClickLabel = uiString(R.string.today_action_how_charge), onClick = onHowCalculated, ) .background(Palette.surfaceInset) @@ -4366,7 +4417,8 @@ private fun RecoveryDriversSection( if (drivers.isEmpty()) return val tier = remember(days, readDay) { chargeConfidenceTier(days, readDay) } - val overline = carriedDay?.let { "Charge · ${carriedCaption(it.day)}" } ?: "Charge" + val overline = carriedDay?.let { uiString(R.string.today_charge_carried, carriedCaption(it.day).localized()) } + ?: uiString(R.string.trends_charge) Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { // Header row: section title + the SURFACED confidence pill (dot + tier tag) on the right. @@ -4375,7 +4427,7 @@ private fun RecoveryDriversSection( horizontalArrangement = Arrangement.spacedBy(Metrics.space8), ) { Box(modifier = Modifier.weight(1f)) { - SectionHeader("What shaped it", overline = overline, trailing = "vs your baseline") + SectionHeader(uiString(R.string.today_what_shaped_it), overline = overline, trailing = uiString(R.string.today_vs_your_baseline)) } ChargeConfidencePill(tier) } @@ -4383,8 +4435,7 @@ private fun RecoveryDriversSection( Column(verticalArrangement = Arrangement.spacedBy(Metrics.space16)) { drivers.forEach { DriverRow(it) } Text( - uiString(R.string.l10n_today_screen_each_line_is_how_many_points_dec2c062) + - "on-device baseline. Approximate, not medical advice.", + uiString(R.string.today_charge_driver_guidance), style = NoopType.footnote, color = Palette.textTertiary, ) @@ -4398,9 +4449,9 @@ private fun RecoveryDriversSection( @Composable private fun ChargeConfidencePill(tier: ScoreConfidence) { val (label, tone) = when (tier) { - ScoreConfidence.SOLID -> "SOLID" to StrandTone.Accent - ScoreConfidence.BUILDING -> "BUILDING" to StrandTone.Warning - ScoreConfidence.CALIBRATING -> "CALIBRATING" to StrandTone.Neutral + ScoreConfidence.SOLID -> uiString(R.string.today_confidence_solid) to StrandTone.Accent + ScoreConfidence.BUILDING -> uiString(R.string.today_confidence_building) to StrandTone.Warning + ScoreConfidence.CALIBRATING -> uiString(R.string.today_confidence_calibrating) to StrandTone.Neutral } StatePill(title = label, tone = tone) } @@ -4417,14 +4468,52 @@ private fun DriverRow(driver: ChargeDriver) { else -> Palette.textTertiary } val signed = if (driver.deltaPoints > 0) "+${driver.deltaPoints}" else "${driver.deltaPoints}" + val label = when (driver.label) { + ChargeDriverLabel.HEART_RATE_VARIABILITY -> uiString(R.string.today_driver_hrv) + ChargeDriverLabel.RESTING_HEART_RATE -> uiString(R.string.today_driver_resting_hr) + ChargeDriverLabel.SLEEP_QUALITY -> uiString(R.string.today_driver_sleep_quality) + ChargeDriverLabel.RESPIRATORY_RATE -> uiString(R.string.today_driver_respiratory) + ChargeDriverLabel.SKIN_TEMPERATURE -> uiString(R.string.today_driver_skin_temperature) + } + val valueText = when (driver.unit) { + ChargeDriverUnit.MILLISECONDS -> uiString(R.string.today_driver_value_ms, driver.value.roundToInt()) + ChargeDriverUnit.BEATS_PER_MINUTE -> uiString(R.string.today_driver_value_bpm, driver.value.roundToInt()) + ChargeDriverUnit.PERCENT -> uiString(R.string.today_driver_value_percent, driver.value.roundToInt()) + ChargeDriverUnit.BREATHS_PER_MINUTE -> uiString( + R.string.today_driver_value_br_min, String.format(Locale.getDefault(), "%.1f", driver.value), + ) + ChargeDriverUnit.CELSIUS_DEVIATION -> uiString( + R.string.today_driver_value_temp_deviation, + String.format(Locale.getDefault(), "%+.1f", driver.value), + ) + } + val baselineText = driver.baseline?.let { baseline -> when (driver.unit) { + ChargeDriverUnit.MILLISECONDS -> uiString(R.string.today_driver_baseline_ms, baseline.roundToInt()) + ChargeDriverUnit.BEATS_PER_MINUTE -> uiString(R.string.today_driver_baseline_bpm, baseline.roundToInt()) + ChargeDriverUnit.BREATHS_PER_MINUTE -> uiString( + R.string.today_driver_baseline_br_min, String.format(Locale.getDefault(), "%.1f", baseline), + ) + ChargeDriverUnit.PERCENT, ChargeDriverUnit.CELSIUS_DEVIATION -> "" + } } ?: "" + val verdict = when (driver.verdict) { + ChargeDriverVerdict.ABOVE_BASELINE_SUPPORTING -> uiString(R.string.today_driver_above_supporting) + ChargeDriverVerdict.BELOW_BASELINE_SUPPORTING -> uiString(R.string.today_driver_below_supporting) + ChargeDriverVerdict.ABOVE_BASELINE_LIMITING -> uiString(R.string.today_driver_above_limiting) + ChargeDriverVerdict.BELOW_BASELINE_LIMITING -> uiString(R.string.today_driver_below_limiting) + ChargeDriverVerdict.AT_BASELINE -> uiString(R.string.today_driver_at_baseline) + ChargeDriverVerdict.HRV_SATURATION_LIMITING -> uiString(R.string.today_driver_hrv_saturation) + ChargeDriverVerdict.STRONG_NIGHT_SUPPORTING -> uiString(R.string.today_driver_strong_night) + ChargeDriverVerdict.BELOW_GOOD_NIGHT_LIMITING -> uiString(R.string.today_driver_below_good_night) + ChargeDriverVerdict.TYPICAL_NIGHT -> uiString(R.string.today_driver_typical_night) + ChargeDriverVerdict.NEAR_BASELINE -> uiString(R.string.today_driver_near_baseline) + ChargeDriverVerdict.WARMER_THAN_BASELINE_LIMITING -> uiString(R.string.today_driver_warmer_limiting) + ChargeDriverVerdict.COOLER_THAN_BASELINE_LIMITING -> uiString(R.string.today_driver_cooler_limiting) + } + val driverA11y = uiString(R.string.today_driver_a11y, label, valueText, baselineText, signed, verdict) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.semantics { - contentDescription = - uiString(R.string.l10n_today_screen_driver_label_driver_valuetext_driver_baselinetext_067bc963, driver.label, driver.valueText, driver.baselineText) + - "$signed points, ${driver.verdict}" - }, + modifier = Modifier.semantics { contentDescription = driverA11y }, ) { // Signed-point delta chip with a direction glyph. Row( @@ -4446,12 +4535,12 @@ private fun DriverRow(driver: ChargeDriver) { Text(uiString(R.string.l10n_today_screen_signed_pts_5ea85678, signed), style = NoopType.captionNumber, color = tone) } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(driver.label, style = NoopType.headline, color = Palette.textPrimary) - Text(driver.verdict, style = NoopType.footnote, color = Palette.textSecondary) + Text(label, style = NoopType.headline, color = Palette.textPrimary) + Text(verdict, style = NoopType.footnote, color = Palette.textSecondary) } Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(driver.valueText, style = NoopType.captionNumber, color = Palette.textPrimary) - Text(driver.baselineText, style = NoopType.footnote, color = Palette.textTertiary) + Text(valueText, style = NoopType.captionNumber, color = Palette.textPrimary) + Text(baselineText, style = NoopType.footnote, color = Palette.textTertiary) } } } @@ -4477,13 +4566,14 @@ private fun RecoveryContributorsSection(day: DailyMetric?, carriedDay: DailyMetr val resp = cd?.respRateBpm if (hrv == null && rhr == null && sleepMin == null && resp == null) return - val overline = carriedDay?.let { "Recovery · ${carriedCaption(it.day)}" } ?: "Recovery" - SectionHeader("Contributors", overline = overline, trailing = "What drove Charge") + val overline = carriedDay?.let { uiString(R.string.today_recovery_carried, carriedCaption(it.day).localized()) } + ?: uiString(R.string.l10n_today_screen_recovery_ea924f72) + SectionHeader(uiString(R.string.today_contributors), overline = overline, trailing = uiString(R.string.today_what_drove_charge)) NoopCard { Column(verticalArrangement = Arrangement.spacedBy(Metrics.space16)) { // HRV, higher is better; map a typical 20–120 ms span. Teal (its biometric hue; iOS metricCyan). ContributorBar( - label = "HRV", + label = uiString(R.string.today_metric_hrv), readout = hrv?.let { "${it.roundToInt()} ms" } ?: NO_DATA, fraction = hrv?.let { ((it - 20.0) / 100.0) }, color = Palette.metricCyan, @@ -4506,7 +4596,7 @@ private fun RecoveryContributorsSection(day: DailyMetric?, carriedDay: DailyMetr // Respiratory, stability around a typical 12–20 rpm span. Deep blue (sleep world). ContributorBar( label = uiString(R.string.l10n_today_screen_respiratory_1cd8c175), - readout = resp?.let { String.format(Locale.US, "%.1f rpm", it) } ?: NO_DATA, + readout = resp?.let { String.format(Locale.getDefault(), "%.1f rpm", it) } ?: NO_DATA, fraction = resp?.let { 1.0 - ((it - 12.0) / 8.0) }, color = Palette.sleepDeep, ) @@ -4528,10 +4618,11 @@ private fun RecoveryContributorsSection(day: DailyMetric?, carriedDay: DailyMetr @Composable private fun ContributorBar(label: String, readout: String, fraction: Double?, color: Color) { val fillFrac = fraction?.coerceIn(0.0, 1.0) ?: 0.0 + val displayReadout = localizedMetricValue(readout) Column(verticalArrangement = Arrangement.spacedBy(Metrics.space6)) { Row(verticalAlignment = Alignment.CenterVertically) { Overline(label, modifier = Modifier.weight(1f)) - Text(readout, style = NoopType.captionNumber, color = Palette.textPrimary) + Text(displayReadout, style = NoopType.captionNumber, color = Palette.textPrimary) } LiquidTube( frac = fillFrac, @@ -4540,7 +4631,7 @@ private fun ContributorBar(label: String, readout: String, fraction: Double?, co animated = false, modifier = Modifier .fillMaxWidth() - .semantics { contentDescription = uiString(R.string.l10n_today_screen_label_readout_3f166607, label, readout) }, + .semantics { contentDescription = uiString(R.string.l10n_today_screen_label_readout_3f166607, label, displayReadout) }, ) } } @@ -4563,11 +4654,10 @@ private fun ContributorBar(label: String, readout: String, fraction: Double?, co @Composable private fun scoreStateTitle(state: ScoreState): String = when (state) { is ScoreState.Scored -> "" - is ScoreState.Calibrating -> uiString(R.string.score_state_title_calibrating) + is ScoreState.Calibrating -> uiString(state.titleRes!!) is ScoreState.CarriedLastNight -> - if (state.stale) uiString(R.string.score_state_title_latest_sleep, state.dateLabel) - else uiString(R.string.score_state_title_last_night, state.dateLabel) - ScoreState.NeedsStrap -> uiString(R.string.score_state_title_needs_strap) + uiString(state.titleRes!!, state.dateLabel) + ScoreState.NeedsStrap -> uiString(state.titleRes!!) } /** The score-state detail line, LOCALIZED. @@ -4659,22 +4749,22 @@ private fun ScoreStateNote(state: ScoreState, restartCause: String? = null) { */ @Composable private fun recordingTitle(state: RecordingState): String = when (state) { - RecordingState.Recording -> uiString(R.string.recording_chip_title_recording) - is RecordingState.LastSynced -> uiString(R.string.recording_chip_title_last_synced, state.minutesAgo) - RecordingState.NotRecording -> uiString(R.string.recording_chip_title_not_recording) + RecordingState.Recording -> uiString(state.titleRes) + is RecordingState.LastSynced -> uiString(state.titleRes, state.minutesAgo) + RecordingState.NotRecording -> uiString(state.titleRes) // Both connected states share the same word, as they do on Swift. RecordingState.HistoryExperimental, RecordingState.ConnectedNoData -> - uiString(R.string.recording_chip_title_connected) + uiString(state.titleRes) } /** The chip's one-line detail, LOCALIZED. Same split as [recordingTitle]. */ @Composable private fun recordingDetail(state: RecordingState): String = when (state) { - RecordingState.Recording -> uiString(R.string.recording_chip_detail_recording) - is RecordingState.LastSynced -> uiString(R.string.recording_chip_detail_last_synced) - RecordingState.NotRecording -> uiString(R.string.recording_chip_detail_not_recording) - RecordingState.HistoryExperimental -> uiString(R.string.recording_chip_detail_history_experimental) - RecordingState.ConnectedNoData -> uiString(R.string.recording_chip_detail_connected_no_data) + RecordingState.Recording -> uiString(state.detailRes) + is RecordingState.LastSynced -> uiString(state.detailRes) + RecordingState.NotRecording -> uiString(state.detailRes) + RecordingState.HistoryExperimental -> uiString(state.detailRes) + RecordingState.ConnectedNoData -> uiString(state.detailRes) } /** The Today/Live recording chip: a tinted StatePill with the status word (a pulsing dot while live), @@ -4867,7 +4957,7 @@ private fun MetricGrid( val v = d?.spo2Pct ?: carriedDay?.spo2Pct ?: spo2CarryDay?.spo2Pct KeyTileData( label = uiString(R.string.l10n_today_screen_blood_oxygen_a8ad9ff5), - value = v?.let { String.format(Locale.US, "%.0f", it) } ?: NO_DATA, + value = v?.let { String.format(Locale.getDefault(), "%.0f", it) } ?: NO_DATA, unit = if (v != null) "%" else "", tint = Palette.metricCyan, frac = v?.let { (it / 100.0).coerceIn(0.0, 1.0) }, @@ -4878,7 +4968,7 @@ private fun MetricGrid( val v = d?.respRateBpm ?: carriedDay?.respRateBpm ?: respCarryDay?.respRateBpm KeyTileData( label = uiString(R.string.l10n_today_screen_respiratory_1cd8c175), - value = v?.let { String.format(Locale.US, "%.1f", it) } ?: NO_DATA, + value = v?.let { String.format(Locale.getDefault(), "%.1f", it) } ?: NO_DATA, unit = if (v != null) "rpm" else "", tint = Palette.accent, frac = v?.let { (it / 24.0).coerceIn(0.0, 1.0) }, @@ -4989,7 +5079,8 @@ private fun MetricGrid( colors = ButtonDefaults.textButtonColors(contentColor = Palette.accent), ) { Text( - if (metricsExpanded) "Show fewer" else "Show all metrics ($hidden)", + if (metricsExpanded) uiString(R.string.today_show_fewer_metrics) + else uiString(R.string.today_show_all_metrics, hidden), style = NoopType.subhead, ) Spacer(Modifier.width(4.dp)) @@ -5057,6 +5148,7 @@ private fun LiquidKeyTile( modifier: Modifier = Modifier, ) { val hasValue = data.value != NO_DATA + val displayValue = localizedMetricValue(data.value) // Tap -> the tile's focused trend detail (the Sleep night-detail tile idiom): liquidPress on the // tappable tile, indication = null so only the liquid settle shows. A null onClick keeps the tile // inert with zero modifier overhead (byte-identical to before). @@ -5073,7 +5165,7 @@ private fun LiquidKeyTile( .clip(RoundedCornerShape(16.dp)) .frostedCardSurface(cornerRadius = 16.dp) .padding(horizontal = 12.dp, vertical = 11.dp) - .semantics { contentDescription = uiString(R.string.l10n_today_screen_data_label_data_value_data_unit_27f6fd6b, data.label, data.value, data.unit).trim() }, + .semantics { contentDescription = uiString(R.string.l10n_today_screen_data_label_data_value_data_unit_27f6fd6b, data.label, displayValue, data.unit).trim() }, verticalArrangement = Arrangement.spacedBy(6.dp), ) { // iOS ktile parity: a small metric glyph before the overline label, tinted to the tile colour at @@ -5100,7 +5192,7 @@ private fun LiquidKeyTile( } Row(verticalAlignment = Alignment.Bottom) { Text( - data.value, + displayValue, style = NoopType.number(17f), color = if (hasValue) Palette.textPrimary else Palette.textTertiary, maxLines = 1, @@ -5198,7 +5290,7 @@ private fun HrWindowPills(selection: HrWindow, onSelect: (HrWindow) -> Unit) { SegmentedPillControl( items = HrWindow.entries.toList(), selection = selection, - label = { it.label }, + label = { uiString(it.labelRes) }, onSelect = onSelect, ) } @@ -5287,9 +5379,9 @@ private fun HeartRateTrendCard( }.getOrDefault(emptyList()) } val selectedLabel = when (selectedDay) { - today -> "Today" - today.minusDays(1) -> "Yesterday" - else -> selectedDay.format(DateTimeFormatter.ofPattern("d MMM", Locale.US)) + today -> uiString(R.string.today_day_today) + today.minusDays(1) -> uiString(R.string.today_day_yesterday) + else -> selectedDay.format(DateTimeFormatter.ofPattern("d MMM", Locale.getDefault())) } // #985 view-only narrowing (the #829 rule): the selected window filters the loaded 5-minute buckets, @@ -5309,21 +5401,21 @@ private fun HeartRateTrendCard( // too-narrow rolling window (say 1h with no recent offload) is never a dead end — the user widens it // or steps back to Today, and the message says which window came up empty. if (winBuckets.size < 2) { - SectionHeader("Heart Rate", overline = selectedLabel) + SectionHeader(uiString(R.string.today_section_heart_rate), overline = selectedLabel) NoopCard { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Overline("Beats per minute") + Overline(uiString(R.string.today_hr_beats_per_minute)) if (selectedDay == today) { HrWindowPills(hrWindow) { hrWindowOrdinal = it.ordinal } } Text( when { selectedDay != today -> - "No heart rate for this day. Step back to a day the strap was worn." + uiString(R.string.today_hr_empty_selected_day) hrWindow != HrWindow.TODAY && buckets.size >= 2 -> - "No heart rate in the last ${hrWindow.label}. Try a wider window or Today." + uiString(R.string.today_hr_empty_window, uiString(hrWindow.labelRes)) else -> - "Calibrating , no heart rate banked yet today. Your curve fills in as the strap offloads." + uiString(R.string.today_hr_calibrating) }, style = NoopType.footnote, color = Palette.textTertiary, @@ -5367,20 +5459,20 @@ private fun HeartRateTrendCard( } val visTimestamps = remember(visBuckets) { visBuckets.map { it.bucket } } - SectionHeader("Heart Rate", overline = selectedLabel) + SectionHeader(uiString(R.string.today_section_heart_rate), overline = selectedLabel) NoopCard { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { // Header, mirrors the macOS ChartCard (title + subtitle, trailing read-out). Row(verticalAlignment = Alignment.Top) { Column(modifier = Modifier.weight(1f)) { - Overline("Beats per minute") + Overline(uiString(R.string.today_hr_beats_per_minute)) // #985: the buckets stay the same 5-minute means whatever the window (view-only // narrowing, no re-read), so the resolution half of the label never changes — only // the span half tells the truth about what's on screen. val subtitle = when { - selectedDay != today -> "5-minute average | selected day" - hrWindow == HrWindow.TODAY -> "5-minute average | since midnight" - else -> "5-minute average | last ${hrWindow.label}" + selectedDay != today -> uiString(R.string.today_hr_average_selected_day) + hrWindow == HrWindow.TODAY -> uiString(R.string.today_hr_average_since_midnight) + else -> uiString(R.string.today_hr_average_last_window, uiString(hrWindow.labelRes)) } Text( subtitle, @@ -5462,7 +5554,11 @@ private fun HeartRateTrendCard( .background(Palette.hairline), ) Row(modifier = Modifier.fillMaxWidth()) { - listOf("Min" to min, "Avg" to avg, "Max" to max).forEach { (label, value) -> + listOf( + uiString(R.string.today_hr_min) to min, + uiString(R.string.today_hr_avg) to avg, + uiString(R.string.today_hr_max) to max, + ).forEach { (label, value) -> Column(modifier = Modifier.weight(1f)) { Overline(label, color = Palette.textTertiary) Text(uiString(R.string.l10n_today_screen_value_bpm_8f3a90c3, value), style = NoopType.bodyNumber, color = Palette.textPrimary) @@ -5476,7 +5572,8 @@ private fun HeartRateTrendCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - if (hrZoom == null) "Pinch to zoom · drag to pan" else "Zoomed in · drag to pan", + if (hrZoom == null) uiString(R.string.timeline_pinch_to_zoom) + else uiString(R.string.today_hr_zoomed_hint), style = NoopType.footnote, color = Palette.textTertiary, modifier = Modifier.weight(1f), @@ -5488,7 +5585,7 @@ private fun HeartRateTrendCard( color = Palette.accent, modifier = Modifier .clip(RoundedCornerShape(50)) - .clickable(onClickLabel = "Reset the heart rate zoom") { hrZoom = null } + .clickable(onClickLabel = uiString(R.string.today_action_reset_hr_zoom)) { hrZoom = null } .padding(horizontal = 6.dp, vertical = 2.dp), ) } @@ -5730,14 +5827,14 @@ private fun OverviewHRChart( // One combined TalkBack description for the overlay layers, so the markers (which are otherwise // small decorative pills) are announced. Only mentions the layers actually present. - val markerDescription = remember(sleep, recovery, strain, workouts, effortScale) { + val markerDescription = run { buildList { - add("24-hour heart rate") - if (sleep != null) add("sleep band ${hrHoursMinutes((sleep.endTs - sleep.effectiveStartTs).toInt())}") - if (recovery != null) add("${recovery.roundToInt()} percent Charge at wake") - if (strain != null) add("${UnitFormatter.effortDisplay(strain, effortScale)} Effort now") - if (workouts.isNotEmpty()) add("${workouts.size} workout${if (workouts.size == 1) "" else "s"} marked") - }.joinToString(", ") + add(uiString(R.string.today_hr_a11y_24_hour)) + if (sleep != null) add(uiString(R.string.today_hr_a11y_sleep_band, hrHoursMinutes((sleep.endTs - sleep.effectiveStartTs).toInt()))) + if (recovery != null) add(uiString(R.string.today_hr_a11y_charge_at_wake, recovery.roundToInt())) + if (strain != null) add(uiString(R.string.today_hr_a11y_effort_now, UnitFormatter.effortDisplay(strain, effortScale))) + if (workouts.isNotEmpty()) add(uiPlural(R.plurals.today_hr_a11y_workouts_marked, workouts.size, workouts.size)) + }.joinToString(uiString(R.string.today_a11y_separator)) } Box( @@ -5986,13 +6083,13 @@ private fun TodayWorkoutsSection(workouts: List) { // "Latest Workouts", not "Last": "Last" read as "final". Mirrored on iOS (TodayView). Lives in // strings.xml (values + values-de) so the header is localizable like the nav labels. - SectionHeader(stringResource(R.string.today_latest_workouts), overline = "Activity", trailing = "14 days") + SectionHeader(stringResource(R.string.today_latest_workouts), overline = uiString(R.string.today_workouts_activity), trailing = uiString(R.string.today_workouts_14_days)) Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { feed.forEach { workout -> StatTile( modifier = Modifier.fillMaxWidth(), label = WorkoutEditing.displaySport(workout.sport), - value = workoutDuration(workout), + value = localizedMetricValue(workoutDuration(workout)), caption = workoutCaption(workout), accent = workout.strain?.let { Palette.effortTint(it / StrainScorer.maxStrain) } ?: Palette.textPrimary, delta = workout.energyKcal?.let { "${it.roundToInt()} kcal" }, @@ -6012,7 +6109,7 @@ private fun TodaySourcesSection( expanded: Boolean = true, onToggle: () -> Unit = {}, ) { - SectionHeader("Data Sources", overline = "Provenance") + SectionHeader(uiString(R.string.today_data_sources), overline = uiString(R.string.today_provenance)) Spacer(Modifier.height(Metrics.gap)) val whoopPresent = (footer.whoopDays ?: 0) > 0 || strapBatteryPct != null val applePresent = (footer.appleDays ?: 0) > 0 || (footer.appleWorkouts ?: 0) > 0 @@ -6029,13 +6126,17 @@ private fun TodaySourcesSection( .clickable( interactionSource = collapsedInteraction, indication = null, - onClickLabel = "Show what NOOP is synced from", + onClickLabel = uiString(R.string.today_action_show_sources), onClick = onToggle, ), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - syncedFromSummary(hasWhoop = whoopPresent, hasApple = applePresent, hasHealthConnect = hcPresent, hasXiaomi = false), + syncedFromSummary(hasWhoop = whoopPresent, hasApple = applePresent, hasHealthConnect = hcPresent, hasXiaomi = false) + .let { names -> + if (names.isEmpty()) uiString(R.string.today_no_sources_yet) + else uiString(R.string.today_synced_from, names.localizedSourceList()) + }, style = NoopType.subhead, color = Palette.textSecondary, maxLines = 1, @@ -6058,7 +6159,7 @@ private fun TodaySourcesSection( Row( modifier = Modifier .fillMaxWidth() - .clickable(onClickLabel = "Hide data source detail", onClick = onToggle), + .clickable(onClickLabel = uiString(R.string.today_action_hide_sources), onClick = onToggle), verticalAlignment = Alignment.CenterVertically, ) { Text(uiString(R.string.l10n_today_screen_synced_from_2aa7258b), style = NoopType.overline, color = Palette.textTertiary, modifier = Modifier.weight(1f)) @@ -6071,12 +6172,12 @@ private fun TodaySourcesSection( } Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(Palette.hairline)) SourceRow( - badge = "Whoop", + badge = uiString(R.string.today_source_whoop), tint = Palette.accent, // A live battery reading means the strap IS connected, even before the first banked // night, don't contradict it with "Not connected" (#159). present = whoopPresent, - detail = countDetail(footer.whoopDays, footer.whoopWorkouts, "workouts"), + detail = countDetail(footer.whoopDays, footer.whoopWorkouts), batteryPct = strapBatteryPct, batteryEstimate = strapBatteryEstimate, ) @@ -6087,10 +6188,10 @@ private fun TodaySourcesSection( .background(Palette.hairline), ) SourceRow( - badge = "Apple Health", + badge = uiString(R.string.today_source_apple_health), tint = Palette.metricCyan, present = applePresent, - detail = countDetail(footer.appleDays, footer.appleWorkouts, "workouts"), + detail = countDetail(footer.appleDays, footer.appleWorkouts), ) Box( modifier = Modifier @@ -6099,10 +6200,10 @@ private fun TodaySourcesSection( .background(Palette.hairline), ) SourceRow( - badge = "Health Connect", + badge = uiString(R.string.today_source_health_connect), tint = Palette.metricPurple, present = hcPresent, - detail = countDetail(footer.hcDays, footer.hcWorkouts, "workouts"), + detail = countDetail(footer.hcDays, footer.hcWorkouts), ) } } @@ -6137,7 +6238,7 @@ private fun SourceRow( } Spacer(Modifier.weight(1f)) Text( - text = if (present) detail else "Not connected", + text = if (present) detail else uiString(R.string.today_not_connected), style = NoopType.captionNumber, color = if (present) Palette.textSecondary else Palette.textTertiary, maxLines = 1, @@ -6154,6 +6255,45 @@ private fun SourceRow( // row per driving signal (a small flag-colored dot + label + detail). The whole card is // suppressed until there is enough history (level == INSUFFICIENT), matching macOS. +@Composable +private fun readinessEvidenceText(evidence: ReadinessEngine.Evidence): String { + fun number(value: Double, decimals: Int): String = if (decimals == 0) { + Math.round(value).toString() + } else { + String.format(Locale.getDefault(), "%.${decimals}f", value) + } + return when (evidence) { + is ReadinessEngine.Evidence.MetricVsBaseline -> { + val unit = when (evidence.unit) { + ReadinessEngine.MetricUnit.MS -> uiString(R.string.today_unit_ms) + ReadinessEngine.MetricUnit.BPM -> uiString(R.string.today_unit_bpm) + ReadinessEngine.MetricUnit.RPM -> uiString(R.string.today_unit_rpm) + } + uiString( + R.string.today_readiness_evidence_metric, + number(evidence.value, evidence.decimals), number(evidence.baseline, evidence.decimals), unit, + ) + } + is ReadinessEngine.Evidence.Monotony -> + uiString(R.string.today_readiness_evidence_monotony, number(evidence.value, 1)) + is ReadinessEngine.Evidence.TrainingLoad -> + uiString( + R.string.today_readiness_evidence_load, + number(evidence.acute, 1), number(evidence.chronic, 1), + ) + } +} + +@Composable +private fun readinessSignalDetail(signal: ReadinessEngine.Signal): String { + val load = signal.evidence as? ReadinessEngine.Evidence.TrainingLoad + return if (load != null && load.chronic > 0.0) { + uiString(signal.detailRes, String.format(Locale.getDefault(), "%.2f", load.acute / load.chronic)) + } else { + uiString(signal.detailRes) + } +} + @Composable private fun ReadinessSection(days: List, carriedDay: DailyMetric? = null) { // Logical day (rolls at 04:00 local), so readiness keeps reading the evening's row in the small @@ -6168,8 +6308,9 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = val readiness = remember(days, anchorKey) { ReadinessEngine.evaluate(days, today = anchorKey) } if (readiness.level == ReadinessEngine.Level.INSUFFICIENT) return - val overline = carriedDay?.let { carriedCaption(it.day) } ?: "Should you push today?" - SectionHeader("Readiness", overline = overline) + val overline = carriedDay?.let { carriedCaption(it.day).localized() } + ?: uiString(R.string.today_should_you_push) + SectionHeader(uiString(R.string.today_readiness_title), overline = overline) NoopCard { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { // Headline row: level dot + headline, then the ACWR load read-out. @@ -6182,14 +6323,14 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = ) Spacer(Modifier.width(10.dp)) Text( - readiness.headline, + uiString(readiness.headlineRes), style = NoopType.headline, color = Palette.textPrimary, modifier = Modifier.weight(1f), ) readiness.acwr?.let { acwr -> Text( - uiString(R.string.today_load_ratio, String.format(Locale.US, "%.2f", acwr)), + uiString(R.string.today_load_ratio, String.format(Locale.getDefault(), "%.2f", acwr)), style = NoopType.captionNumber, color = Palette.textTertiary, ) @@ -6200,14 +6341,14 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = // Charge %). Stated at the point the two get compared, so "Primed" here vs "Steady" there // doesn't read as one value contradicting itself. Raw string, matching this card's copy. Text( - "A training read, separate from your Charge score.", + uiString(R.string.today_training_read_explanation), style = NoopType.footnote, color = Palette.textTertiary, ) // Plain-English summary. Text( - readiness.summary, + uiString(readiness.summaryRes), style = NoopType.subhead, color = Palette.textSecondary, ) @@ -6231,7 +6372,7 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = ) Spacer(Modifier.width(8.dp)) Text( - signal.label, + uiString(signal.labelRes), style = NoopType.caption, color = Palette.textSecondary, modifier = Modifier.width(104.dp), @@ -6241,14 +6382,14 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = verticalArrangement = Arrangement.spacedBy(1.dp), ) { Text( - signal.detail, + readinessSignalDetail(signal), style = NoopType.caption, color = Palette.textTertiary, ) // The numbers behind the read (e.g. "48 vs 55 ms"), as a small mono caption, // mirrors the macOS readiness card and the "load X.XX" numeric readout above. signal.evidence?.let { evidence -> Text( - evidence, + readinessEvidenceText(evidence), style = NoopType.captionNumber, color = Palette.textTertiary, ) @@ -6284,11 +6425,11 @@ private fun flagColor(flag: ReadinessEngine.Flag): Color = when (flag) { * semantics exactly (cross-platform parity). Returns (null, "") for any other code so an unmapped value * shows nothing rather than a wrong glyph. */ -private fun stepActivityIconFor(activityClass: Int): Pair = when (activityClass) { - 0 -> Icons.Filled.Accessibility to "Still" - 1 -> Icons.AutoMirrored.Filled.DirectionsWalk to "Walking" - 2 -> Icons.AutoMirrored.Filled.DirectionsRun to "Running" - else -> null to "" +private fun stepActivityIconFor(activityClass: Int): Pair = when (activityClass) { + 0 -> Icons.Filled.Accessibility to R.string.today_steps_activity_still + 1 -> Icons.AutoMirrored.Filled.DirectionsWalk to R.string.today_steps_activity_walking + 2 -> Icons.AutoMirrored.Filled.DirectionsRun to R.string.today_steps_activity_running + else -> null to null } // MARK: - SparkStatTile @@ -6313,6 +6454,7 @@ private fun SparkStatTile( // glyph in the label row, tinted with the tile accent. null = no icon. Used by the Steps tile. trailingIcon: Int? = null, ) { + val displayValue = localizedMetricValue(value) NoopCard(modifier = modifier.height(Metrics.tileHeight), padding = Metrics.space14) { Column(modifier = Modifier.fillMaxWidth()) { // Label row carries the overline, an optional low-confidence [badge] (H9, e.g. "Estimated" @@ -6333,11 +6475,11 @@ private fun SparkStatTile( Spacer(Modifier.weight(1f)) // #316, still/walk/run glyph, before the optional ⓘ. Self-hides for an unknown code. if (trailingIcon != null) { - val (vector, desc) = stepActivityIconFor(trailingIcon) - if (vector != null) { + val (vector, descRes) = stepActivityIconFor(trailingIcon) + if (vector != null && descRes != null) { Icon( vector, - contentDescription = desc, + contentDescription = uiString(descRes), tint = accent, modifier = Modifier.size(16.dp), ) @@ -6362,7 +6504,7 @@ private fun SparkStatTile( // the value silently truncates at full size. The plain StatTile worked because it // passes weight(1f) (a hard width); this column-child needs fillMaxWidth instead. AutoSizeValue( - value, + displayValue, style = NoopType.tileValueLarge, color = accent, modifier = Modifier.fillMaxWidth(), @@ -6467,43 +6609,49 @@ private fun rememberTrendWindow( // MARK: - Derived text (ported from TodayView.swift) +@Composable private fun greetingWord(): String { val h = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) return when { - h < 12 -> "Good morning" - h < 17 -> "Good afternoon" - else -> "Good evening" + h < 12 -> uiString(R.string.today_greeting_morning) + h < 17 -> uiString(R.string.today_greeting_afternoon) + else -> uiString(R.string.today_greeting_evening) } } +@Composable private fun synthesisWord(score: Double?): String { - if (score == null) return "No Data" + if (score == null) return uiString(R.string.today_no_data) // #1405: these are CHARGE/recovery-level words, a different axis from the ReadinessEngine training // verdict (Run down / Strained / Balanced / Primed). They must NOT share a word, or the Synthesis // card ("Steady") and the Charge-breakdown Readiness card ("Primed") read as the same thing // contradicting itself. So the [70,88) band is "Strong" (which also matches this card's own "Charge is // strong" detail copy), leaving "Primed" exclusively to the readiness engine. Keep parity with Swift. return when { - score < 25 -> "Depleted" - score < 50 -> "Low" - score < 70 -> "Steady" - score < 88 -> "Strong" - else -> "Peak" + score < 25 -> uiString(R.string.today_synthesis_depleted) + score < 50 -> uiString(R.string.today_synthesis_low) + score < 70 -> uiString(R.string.today_synthesis_steady) + score < 88 -> uiString(R.string.today_synthesis_strong) + else -> uiString(R.string.today_synthesis_peak) } } +@Composable private fun synthesisDetail(d: DailyMetric?): String { val rec = d?.recovery - ?: return "No metrics yet. Import your WHOOP export or wear the strap to begin." - val recPart = when { - rec < 50 -> "Charge is low" - rec < 70 -> "Charge is steady" - else -> "Charge is strong" - } - val sleepPart = d.totalSleepMin?.let { mins -> - if (mins / 60.0 >= 7) " and sleep was consistent" else " but sleep ran short" - } ?: "" - return "$recPart$sleepPart." + ?: return uiString(R.string.today_synthesis_no_metrics) + val res = when { + rec < 50 && d.totalSleepMin == null -> R.string.today_synthesis_charge_low + rec < 50 && d.totalSleepMin!! / 60.0 >= 7 -> R.string.today_synthesis_charge_low_sleep_consistent + rec < 50 -> R.string.today_synthesis_charge_low_sleep_short + rec < 70 && d.totalSleepMin == null -> R.string.today_synthesis_charge_steady + rec < 70 && d.totalSleepMin!! / 60.0 >= 7 -> R.string.today_synthesis_charge_steady_sleep_consistent + rec < 70 -> R.string.today_synthesis_charge_steady_sleep_short + d.totalSleepMin == null -> R.string.today_synthesis_charge_strong + d.totalSleepMin!! / 60.0 >= 7 -> R.string.today_synthesis_charge_strong_sleep_consistent + else -> R.string.today_synthesis_charge_strong_sleep_short + } + return uiString(res) } private fun sleepValue(d: DailyMetric?): String { @@ -6519,32 +6667,44 @@ private fun sleepValue(d: DailyMetric?): String { */ private fun restCaption(d: DailyMetric?): String? = when { d?.totalSleepMin != null -> sleepValue(d) - d?.efficiency != null -> String.format(Locale.US, "%.0f%% eff", d.efficiency) + d?.efficiency != null -> String.format(Locale.getDefault(), "%.0f%% eff", d.efficiency) else -> null } /** Group-separated integer display from a Double (e.g. 12 345 steps), matching the Apple Health tiles. */ private fun intString(v: Double): String { val n = v.roundToInt() - return if (kotlin.math.abs(n) >= 1000) String.format(Locale.US, "%,d", n) else "$n" + return if (kotlin.math.abs(n) >= 1000) String.format(Locale.getDefault(), "%,d", n) else "$n" } -private const val NO_DATA = "No Data" +private const val NO_DATA = "noop:no-data" /** The dashboard-card placeholder for a baseline-relative metric (Stress) that is still seeding its window, * an honest "building your baseline" state rather than a bare dash (#706/#684). Rendered dimmed like NO_DATA. */ -private const val STRESS_CALIBRATING = "Calibrating" +private const val STRESS_CALIBRATING = "noop:calibrating" + +@Composable +private fun localizedMetricValue(value: String): String = when (value) { + NO_DATA -> uiString(R.string.today_no_data) + STRESS_CALIBRATING -> uiString(R.string.today_calibrating) + else -> value +} private val workoutDateFmt: DateTimeFormatter = - DateTimeFormatter.ofPattern("d MMM", Locale.US).withZone(ZoneId.systemDefault()) + DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()).withZone(ZoneId.systemDefault()) private val workoutTimeFmt: DateTimeFormatter = // Respect the device's 12-/24-hour locale (#337): "7:10 AM" where 12-hour is preferred, "19:10" // where 24-hour is, instead of forcing 24-hour on everyone. DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) .withLocale(Locale.getDefault()).withZone(ZoneId.systemDefault()) -private fun countDetail(days: Int?, workouts: Int?, workoutLabel: String): String { - if (days == null || workouts == null) return "Counting..." - return "${grouped(days)} days · ${grouped(workouts)} $workoutLabel" +@Composable +private fun countDetail(days: Int?, workouts: Int?): String { + if (days == null || workouts == null) return uiString(R.string.today_source_counting) + return uiString( + R.string.today_source_count_joiner, + uiPlural(R.plurals.today_source_days, days, grouped(days)), + uiPlural(R.plurals.today_source_workouts, workouts, grouped(workouts)), + ) } /** Same bands as the Settings Strap battery pill, so the % reads the same colour everywhere (#159). */ @@ -6577,7 +6737,7 @@ private fun workoutCaption(row: WorkoutRow): String { } private fun grouped(value: Int): String = - String.format(Locale.US, "%,d", value) + String.format(Locale.getDefault(), "%,d", value) // MARK: - Key-Metrics layout editor (#251) // @@ -6586,10 +6746,11 @@ private fun grouped(value: Int): String = // `today.keyMetrics` layout, never any stored metric. Uses the shared Shown / Hidden editor rows. /** The Key-Metrics header's trailing label for the chosen detailed-graph window. */ +@Composable private fun trendWindowLabel(days: Int): String = when (days) { - 7 -> "7-day trend" - 30 -> "30-day trend" - else -> "14-day trend" + 7 -> uiString(R.string.today_trend_7_days) + 30 -> uiString(R.string.today_trend_30_days) + else -> uiString(R.string.today_trend_14_days) } @Composable @@ -6600,6 +6761,8 @@ private fun KeyMetricsEditorDialog( onDismiss: () -> Unit, onSave: (List, Boolean, Int) -> Unit, ) { + val context = LocalContext.current + val titles = KeyMetric.entries.associateWith { uiString(it.titleRes) } // Detailed tiles: taller/squarer with a trend graph under the fill bar (display-only), over the // chosen trailing window (1 week / 2 weeks / 1 month). var detailed by remember { mutableStateOf(initialDetailed) } @@ -6661,7 +6824,11 @@ private fun KeyMetricsEditorDialog( SegmentedPillControl( items = listOf(7, 14, 30), selection = windowDays, - label = { when (it) { 7 -> "1 week"; 14 -> "2 weeks"; else -> "1 month" } }, + label = { when (it) { + 7 -> context.getString(R.string.today_range_1_week) + 14 -> context.getString(R.string.today_range_2_weeks) + else -> context.getString(R.string.today_range_1_month) + } }, onSelect = { windowDays = it }, modifier = Modifier.fillMaxWidth(), ) @@ -6671,7 +6838,7 @@ private fun KeyMetricsEditorDialog( EditableVisibilityRows( shown = shown, hidden = hidden, - itemTitle = { it.title }, + itemTitle = { titles.getValue(it) }, ) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index ca0475a8ee..d72f87c0af 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1677,6 +1677,7 @@ %1$s%% Profil und Einstellungen Erholung + jetzt Live Verlaufssynchronisierung des Straps läuft, %1$d Chunks Verlauf des Straps vor %1$s synchronisiert @@ -2130,6 +2131,303 @@ Als Anfänger-Standard nicht empfohlen, außer Ziel ist, Müdigkeit zu durchbrechen. Beruhige mich + + Charge / Effort / Erholung + Session starten + Zusammenfassung + Wichtige Messwerte + Trainingseinheiten + Herzfrequenz + Erholungswerte + Deine Karten + Menstruationszyklus + Tagebuch + Hinzugefügte Karten + Heute + 24 Std. + 12 Std. + 6 Std. + 3 Std. + 1 Std. + aktuell + aus dem Profil + Auf dem Gerät + WHOOP + Apple Health + Google Health Connect + Xiaomi Mi Band + Ernährung + Zwischengespeichert + Apple Watch + Oura Ring + Fitbit-Tracker + Garmin-Uhr + Workout-Dateien + " + " + Belasten + Halten + Erholen + Noch keine Quellen + Synchronisiert von: %1$s + Neu gestartet, als du am %1$s neu kalibriert hast — du musst nicht noch einmal tippen. + Wird aufgebaut — heute Nacht tragen + Wird aufgebaut — steigt mit deiner Aktivität + So wird %1$s berechnet + So wird dieser Wert berechnet + Strap-Akku + Strap-Akku: %1$d Prozent + STABIL + Charge-Wert · %1$s + Erholung · %1$s + Weniger anzeigen + Alle Messwerte anzeigen (%1$d) + Keine Herzfrequenzdaten für diesen Tag. Gehe zu einem Tag zurück, an dem du den Strap getragen hast. + Keine Herzfrequenzdaten in den letzten %1$s. Wähle einen größeren Zeitraum oder „Heute“. + Kalibrierung läuft. Heute wurden noch keine Herzfrequenzdaten gespeichert. Die Kurve füllt sich, sobald der Strap Daten überträgt. + 5-Minuten-Durchschnitt | letzte %1$s + Vergrößert · zum Verschieben ziehen + Nicht verbunden + Solltest du heute Gas geben? + Eine Trainingseinschätzung, unabhängig von deinem Charge-Wert. + Sieht nach einem Training gegen %1$s–%2$s aus (Ø HF %3$d, %4$d Min.). Speichern? + Sieht nach einem Training gestern gegen %1$s–%2$s aus (Ø HF %3$d, %4$d Min.). Speichern? + Sieht nach einem Training am %1$s gegen %2$s–%3$s aus (Ø HF %4$d, %5$d Min.). Speichern? + HRV + Herzfrequenzvariabilität + Ruhepuls + Ruhepuls + Atemfrequenz + Atemzüge pro Minute + Blutsauerstoff + Blutsauerstoff + Hauttemperatur + Hauttemperatur + Schlaf + Letzte Nacht + Schritte + Heute + Kalorien + Aktive Energie + Stress + Autonome Belastung + Fitnessalter + Wöchentliche Aktualisierung + VO₂max + Geschätzt, wöchentlich aktualisiert + Vitalität + Wohlbefinden + Flüssigkeit + Heutige Flüssigkeit + Gesamtansicht + Erholung, Belastung und Schlaf auf einen Blick + Energie + Belastung + Erholung + HRV + Ruhepuls + Blutsauerstoff + Atemfrequenz + Schritte + Gewicht + Kalorien + Bereitschaft + Trage das Band einige Nächte; danach erscheint hier deine Bereitschaftseinschätzung. + HRV + über deinem Basiswert - gut erholt + In deinem normalen Bereich + etwas unter dem Basiswert + unterdrückt - ein Zeichen autonomer Ermüdung + Ruhepuls + auf oder unter dem Basiswert + leicht erhöht + erhöht - Übertraining oder Krankheit können die Ursache sein + Atmung + über dem Basiswert - manchmal ein frühes Anzeichen einer Erkrankung + leicht über dem Basiswert + Trainingsvielfalt + niedrig - ähnliche Belastung an jedem Tag erhöht das Belastungs- und Krankheitsrisiko + Trainingslast + abnehmend (akut:chronisch %1$s) - Raum für mehr Belastung + im optimalen Bereich (akut:chronisch %1$s) + schneller Aufbau (akut:chronisch %1$s) - auf Ermüdung achten + Spitze (akut:chronisch %1$s) - höheres Verletzungsrisiko + Mit einigen weiteren Datennächten wird deine Bereitschaftseinschätzung genauer. + Erschöpft + Mehrere Signale sind gleichzeitig schwach. Nutze den Tag zur Erholung: leichte Bewegung und heute Nacht richtig schlafen. + Beansprucht + Eines deiner Signale fällt auf. Du kannst trainieren, aber halte es kontrolliert und sichere dir Erholung. + Bereit + Deine Signale sind im Einklang und deine Last ist gut gestützt. Eine härtere Einheit ist heute gut abgesichert. + Ausgeglichen + Kein Signal fällt auf. Trainiere nach Gefühl - dein Körper bleibt stabil. + Tag ändern + Einklappen + Vollständige Einschätzung öffnen + Vollständige Bereitschaft anzeigen + Datenquellen ausblenden + So wird Energie berechnet + Herzfrequenz-Zoom zurücksetzen + Einflüsse auf %1$s anzeigen + Synchronisierte NOOP-Quellen anzeigen + Einflussfaktoren + Datenquellen + Schläge pro Minute + Keine Daten + Herkunft + 7 T. %1$s / 28 T. %2$s + %1$s vs. %2$s %3$s + Monotonie %1$s + k=%1$s aus %2$d Tagen, %3$s Konfidenz + Kalibrierung: %1$d/%2$d Tage + hoch + niedrig + mittel + geschätzt · %1$s + geschätzt + manuell k=%1$s + Die Energie ist niedrig. + Die Energie ist niedrig und dein Schlaf war beständig. + Die Energie ist niedrig und dein Schlaf war zu kurz. + Die Energie ist stabil. + Die Energie ist stabil und dein Schlaf war beständig. + Die Energie ist stabil, aber dein Schlaf war zu kurz. + Die Energie ist stark. + Die Energie ist stark und dein Schlaf war beständig. + Die Energie ist stark, aber dein Schlaf war zu kurz. + Erschöpft + Niedrig + Noch keine Messwerte. Importiere deinen WHOOP-Export oder trage dein Band. + Spitze + Stabil + Stark + bpm + ms + Atemz./Min. + vs. deinem Basiswert + Einflüsse auf Energie + Das hat beeinflusst + 14 Tage + Aktivität + Manuell kalibriert + Aus %1$d Tagen mit Smartphone-Schritten geschätzt + Verbinde die Schrittzählung deines Smartphones, um Schritte zu schätzen + Noch %1$d Tage mit Smartphone-Schritten nötig + Dein Basiswert wird gelernt, %1$d von %2$d Nächten. + Letzte Nacht · %1$s + Min. + Ø + Max. + Wird gezählt… + %1$s Tage · %2$s Trainings + Heute · %1$s + Gestern · %1$s + Heute + Gestern + Dein Basiswert wird aufgebaut + Energie, Belastung und Erholung werden nach einigen Nächten individuell. + Live aktiv. Deine Werte werden aufgebaut. + Energie, Belastung und Erholung entstehen in den nächsten Nächten. + Deine Live-Herzfrequenz kommt bereits vom Band. Erholung, Belastung und Schlaf entstehen in den nächsten Nächten und werden mit deinem Basiswert genauer. Für den vollständigen Verlauf kannst du deinen WHOOP-Export unter Datenquellen importieren; er wird in etwa einer Minute ergänzt. + Sitzung läuft + Sitzung beendet + Sitzung starten + Überwachung aktiv - solange es still bleibt, liegst du richtig. + Zusammenfassung deiner letzten Sitzung anzeigen. + Bandgeführte Belastungseinheit. Das Band vibriert nur, wenn du den heutigen Bereich verlässt. + BETA + %1$s · letzte Nacht + Wähle die Karten für Heute. Karten ohne Wert zeigen einen Strich. + Jede Zeile zeigt, um wie viele Punkte dieses Signal deine Energie gegenüber deinem geräteinternen Basiswert verändert hat. Näherungswert, keine medizinische Beratung. + STABIL + IM AUFBAU + KALIBRIERUNG + 5-Minuten-Mittel | ausgewählter Tag + 5-Minuten-Mittel | seit Mitternacht + Herzfrequenz über 24 Stunden + Schlafbereich %1$s + %1$d Prozent Energie beim Aufwachen + %1$s Belastung jetzt + , + Still + Gehen + Laufen + Guten Morgen + Guten Tag + Guten Abend + Kalibrierung + %1$s · %2$s + 7-Tage-Trend + 14-Tage-Trend + 30-Tage-Trend + 1 Woche + 2 Wochen + 1 Monat + %1$s %2$s. + + noch ~%1$d Stunde + noch ~%1$d Stunden + + + noch ~%1$d Tag + noch ~%1$d Tage + + + %1$d neuer Verlaufstag ist in Trends verfügbar. + %1$d neue Verlaufstage sind in Trends verfügbar. + + + %1$d Training markiert + %1$d Trainings markiert + + + %1$s Tag + %1$s Tage + + + %1$s Training + %1$s Trainings + + Herzfrequenzvariabilität + Ruhepuls + Schlafqualität + Atemfrequenz + Hauttemperatur + %1$d ms + %1$d bpm + %1$d%% + %1$s Atemz./Min. + %1$s °C gegenüber Basiswert + Basiswert %1$d ms + Basiswert %1$d bpm + Basiswert %1$s Atemz./Min. + über dem Basiswert, unterstützt die Erholung + unter dem Basiswert, unterstützt die Erholung + über dem Basiswert, begrenzt die Erholung + unter dem Basiswert, begrenzt die Erholung + auf dem Basiswert + unter dem Basiswert, begrenzt die Erholung; der niedrige Ruhepuls deutet jedoch eher auf parasympathische Sättigung als auf Ermüdung hin + eine starke Nacht, unterstützt die Erholung + unter dem Niveau einer guten Nacht, begrenzt die Erholung + eine typische Nacht + nahe am Basiswert + wärmer als der Basiswert, begrenzt die Erholung + kühler als der Basiswert, begrenzt die Erholung + %1$s, %2$s, %3$s, %4$s Punkte, %5$s + Deine Tageswerte + Die drei Werte + NOOP gibt dir drei Tageswerte: Energie, Belastung und Erholung, jeweils von 0 bis 100. Sie werden auf deinem Gerät aus den Signalen des Straps und veröffentlichter Sportwissenschaft berechnet. Es sind eigenständige Näherungswerte, nicht WHOOPs private Werte. Daher kann die Entwicklung ähnlich sein, während die Zahlen abweichen. + Energie: Wie gut bist du erholt? + Maßgeblich ist deine HRV im Vergleich zur persönlichen Basislinie. Hinzu kommen Ruheherzfrequenz, die Erholung der letzten Nacht, Atemfrequenz und Hauttemperatur. Eine höhere HRV bedeutet mehr Energie. NOOP benötigt einige Nächte, um deine Basislinie zu lernen; bis dahin wird „Kalibrierung“ angezeigt. + Die Idee ähnelt WHOOPs Recovery-Wert. Unsere Gewichtung und Berechnung der Basislinie sind jedoch eigenständig und offen dokumentiert. + Belastung: Wie stark hat dein Herz gearbeitet? + Belastung misst die Beanspruchung des Herz-Kreislauf-Systems. Die Herzfrequenz wird mithilfe von Herzfrequenzreserve-Zonen in einen Trainingsimpuls umgerechnet; intensive Zonen zählen stärker. Das Ergebnis liegt auf einer logarithmischen Skala von 0 bis 100. Schritte und aktive Energie bilden eine Untergrenze für lange Aktivitäten mit geringer Herz-Kreislauf-Belastung. + Die Idee ähnelt WHOOPs Day Strain (0–21), wurde aber auf 0–100 skaliert. Eine 100 bleibt so selten wie eine 21,0. + Erholung: Wie erholsam war dein Schlaf? + Erholung kombiniert Schlafdauer im Verhältnis zu deinem Bedarf, Schlafeffizienz, erholsamen Tief- und REM-Schlaf sowie die Regelmäßigkeit deiner Schlaf- und Aufwachzeiten. + Die Idee ähnelt WHOOPs Sleep Performance; unsere Berechnung ist jedoch eigenständig. + Stabil + Im Aufbau + Jeder Wert zeigt seine Verlässlichkeit. „Kalibrierung“ bedeutet, dass NOOP deine Basislinie noch lernt oder mehr Daten benötigt. „Im Aufbau“ bedeutet, dass genügend, aber noch wenige Daten vorliegen. „Stabil“ bedeutet, dass alle Eingaben vorhanden sind. Ist keine verlässliche Berechnung möglich, zeigt NOOP lieber keinen Wert als eine Schätzung. Trainingslast CTL · Kondition ATL · Ermüdung diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index b6d4f7379c..1f27dae7c6 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1474,6 +1474,7 @@ %1$s%% Perfil y ajustes Recuperación + ahora en vivo Sincronizando el historial de la pulsera, %1$d bloques Historial de la pulsera sincronizado hace %1$s @@ -1492,7 +1493,7 @@ Esfuerzo Sincronizado desde SÍNTESIS - %1$s Effort + %1$s Esfuerzo %1$s bpm %1$s %1$s @@ -2116,6 +2117,303 @@ No recomendado como valor por defecto para principiantes salvo que el objetivo sea superar la somnolencia. Cálmame + + Carga / Esfuerzo / Descanso + Iniciar sesión + Resumen + Métricas clave + Entrenamientos + Frecuencia cardíaca + Signos de recuperación + Tus tarjetas + Ciclo menstrual + Diario + Tarjetas añadidas + Hoy + 24 h + 12 h + 6 h + 3 h + 1 h + más reciente + del perfil + En el dispositivo + WHOOP + Apple Health + Google Health Connect + Xiaomi Mi Band + Nutrición + En caché + Apple Watch + Anillo Oura + Dispositivo Fitbit + Reloj Garmin + Archivos de entrenamiento + " + " + Exígete + Mantén + Descansa + Aún no hay fuentes + Sincronizado desde: %1$s + Se reinició al recalibrar el %1$s; no hace falta volver a tocarlo. + En proceso; úsala esta noche + En proceso; avanza con tu actividad + Cómo se calcula %1$s + Cómo se calcula esta puntuación + Batería de la pulsera + Batería de la pulsera: %1$d por ciento + SÓLIDO + Carga · %1$s + Recuperación · %1$s + Mostrar menos + Mostrar todas las métricas (%1$d) + No hay datos de frecuencia cardíaca para este día. Vuelve a un día en que llevaras la pulsera. + No hay datos de frecuencia cardíaca en las últimas %1$s. Prueba un intervalo mayor o «Hoy». + Calibrando. Aún no se han guardado datos de frecuencia cardíaca hoy. La curva se completará cuando la pulsera transfiera los datos. + Media de 5 minutos | últimas %1$s + Ampliado · arrastra para desplazarte + Sin conexión + ¿Deberías exigirte hoy? + Una valoración de entrenamiento, independiente de tu puntuación de Carga. + Parece que entrenaste entre las %1$s y las %2$s (FC media %3$d, %4$d min). ¿Guardarlo? + Parece que entrenaste ayer entre las %1$s y las %2$s (FC media %3$d, %4$d min). ¿Guardarlo? + Parece que entrenaste el %1$s entre las %2$s y las %3$s (FC media %4$d, %5$d min). ¿Guardarlo? + VFC + Variabilidad de la frecuencia cardíaca + FC en reposo + Frecuencia cardíaca en reposo + Respiración + Respiraciones por minuto + Oxígeno en sangre + Oxígeno en sangre + Temperatura de la piel + Temperatura de la piel + Sueño + Anoche + Pasos + Hoy + Calorías + Energía activa + Estrés + Carga autonómica + Edad física + Actualización semanal + VO₂ máximo + Estimado, actualización semanal + Vitalidad + Puntuación de bienestar + Hidratación + Líquido de hoy + Vista combinada + Recuperación, esfuerzo y sueño de un vistazo + Carga + Esfuerzo + Descanso + HRV + FC en reposo + Oxígeno en sangre + Respiración + Pasos + Peso + Calorías + Disposición + Lleva la pulsera varias noches y aquí aparecerá tu lectura de disposición. + HRV + por encima de tu referencia: buena recuperación + Dentro de tu rango normal + ligeramente por debajo de la referencia + suprimida: señal de fatiga autonómica + FC en reposo + en la referencia o por debajo + un poco alta + elevada: el sobreentrenamiento o una enfermedad pueden causarlo + Respiración + por encima de la referencia: a veces es una señal temprana de enfermedad + ligeramente elevada frente a la referencia + Variedad de entrenamiento + baja: repetir un esfuerzo similar cada día aumenta el riesgo de sobrecarga o enfermedad + Carga de entrenamiento + disminuyendo (aguda:crónica %1$s) - margen para aumentar + en el rango óptimo (aguda:crónica %1$s) + aumento rápido (aguda:crónica %1$s) - vigila la fatiga + pico (aguda:crónica %1$s) - mayor riesgo de lesión + Con unas noches más de datos, tu lectura de disposición será más precisa. + Agotado + Varias señales están bajas a la vez. Dedica hoy a recuperarte: movimiento suave y sueño reparador esta noche. + Sobrecargado + Una señal está dando aviso. Puedes entrenar, pero mantén el control y reserva recuperación. + Preparado + Tus señales están alineadas y la carga está respaldada. Hoy tienes buen respaldo para una sesión más dura. + Equilibrado + Ninguna señal da aviso. Entrena según tus sensaciones: tu cuerpo se mantiene estable. + Cambiar día + Contraer + Abrir la lectura completa + Ver toda tu disposición + Ocultar fuentes de datos + Cómo se calcula la carga + Restablecer zoom de frecuencia cardíaca + Ver qué influyó en %1$s + Mostrar las fuentes sincronizadas con NOOP + Factores + Fuentes de datos + Latidos por minuto + Sin datos + Procedencia + 7 d %1$s / 28 d %2$s + %1$s frente a %2$s %3$s + Monotonía %1$s + k=%1$s de %2$d días, confianza %3$s + Calibrando: %1$d/%2$d días + alta + baja + media + estim. · %1$s + estim. + k manual=%1$s + La carga es baja. + La carga es baja y el sueño fue constante. + La carga es baja y faltó sueño. + La carga es estable. + La carga es estable y el sueño fue constante. + La carga es estable, pero faltó sueño. + La carga es alta. + La carga es alta y el sueño fue constante. + La carga es alta, pero faltó sueño. + Agotado + Bajo + Aún no hay métricas. Importa tu exportación de WHOOP o lleva la pulsera. + Máximo + Estable + Fuerte + lpm + ms + resp./min + frente a tu referencia + Qué influyó en la carga + Qué influyó + 14 días + Actividad + Calibrado manualmente + Estimado con %1$d días contabilizados también por el teléfono + Conecta el recuento de pasos del teléfono para estimar pasos + Faltan %1$d días contabilizados también por el teléfono + Creando tu referencia, %1$d de %2$d noches. + Anoche · %1$s + Mín. + Media + Máx. + Contando… + %1$s días · %2$s entrenamientos + Hoy · %1$s + Ayer · %1$s + Hoy + Ayer + Creando tu referencia + La carga, el esfuerzo y el descanso se personalizan tras varias noches de uso. + En directo. Tus puntuaciones se están creando. + La carga, el esfuerzo y el descanso se crearán durante las próximas noches. + La frecuencia cardíaca en directo ya funciona desde la pulsera. La recuperación, el esfuerzo y el sueño se crean durante las próximas noches y mejoran al aprender tu referencia. Para obtener todo tu historial, importa tu exportación de WHOOP en Fuentes de datos; se completará en aproximadamente un minuto. + Sesión en curso + Sesión finalizada + Iniciar sesión + Control activo: si no vibra, sigues en el rango. + Ver el resumen de tu última sesión. + Sesión de esfuerzo guiada por la pulsera. Solo vibra si te sales del rango de hoy. + BETA + %1$s · anoche + Elige las tarjetas que aparecen en Hoy. Las tarjetas sin valor muestran un guion. + Cada línea muestra cuántos puntos movió esa señal la carga frente a tu referencia calculada en el dispositivo. Es una aproximación, no un consejo médico. + SÓLIDA + EN PROGRESO + CALIBRANDO + Media de 5 minutos | día seleccionado + Media de 5 minutos | desde medianoche + Frecuencia cardíaca de 24 horas + franja de sueño %1$s + Carga al despertar: %1$d por ciento + Esfuerzo actual: %1$s + , + Quieto + Caminando + Corriendo + Buenos días + Buenas tardes + Buenas noches + Calibrando + %1$s · %2$s + Tendencia de 7 días + Tendencia de 14 días + Tendencia de 30 días + 1 semana + 2 semanas + 1 mes + %1$s %2$s. + + queda ~%1$d hora + quedan ~%1$d horas + + + queda ~%1$d día + quedan ~%1$d días + + + %1$d nuevo día de historial está listo en Tendencias. + %1$d nuevos días de historial están listos en Tendencias. + + + %1$d entrenamiento marcado + %1$d entrenamientos marcados + + + %1$s día + %1$s días + + + %1$s entrenamiento + %1$s entrenamientos + + Variabilidad de la frecuencia cardíaca + Frecuencia cardíaca en reposo + Calidad del sueño + Frecuencia respiratoria + Temperatura de la piel + %1$d ms + %1$d lpm + %1$d%% + %1$s resp./min + %1$s °C frente a la referencia + referencia de %1$d ms + referencia de %1$d lpm + referencia de %1$s resp./min + por encima de la referencia, favorece la recuperación + por debajo de la referencia, favorece la recuperación + por encima de la referencia, limita la recuperación + por debajo de la referencia, limita la recuperación + en la referencia + por debajo de la referencia, limita la recuperación; la baja FC en reposo sugiere que podría ser saturación parasimpática y no fatiga + una noche buena, favorece la recuperación + por debajo de una buena noche, limita la recuperación + una noche típica + cerca de la referencia + más alta que la referencia, limita la recuperación + más baja que la referencia, limita la recuperación + %1$s, %2$s, %3$s, %4$s puntos, %5$s + Tus puntuaciones diarias + Las tres puntuaciones + NOOP ofrece tres puntuaciones diarias: Carga, Esfuerzo y Descanso, de 0 a 100. Se calculan en tu dispositivo a partir de las señales de la pulsera y ciencia deportiva publicada. Son estimaciones independientes, no las puntuaciones privadas de WHOOP; por eso pueden evolucionar de forma similar sin coincidir. + Carga: ¿qué tan recuperado estás? + La Carga se basa principalmente en tu VFC frente a la línea base personal, además de la frecuencia cardíaca en reposo, el Descanso de anoche, la respiración y la temperatura de la piel. Una VFC más alta implica más Carga. NOOP necesita varias noches para aprender tu línea base; mientras tanto muestra «Calibrando». + La idea es similar al Recovery % de WHOOP, pero nuestra ponderación y línea base son independientes y están documentadas. + Esfuerzo: ¿cuánto trabajó tu corazón? + El Esfuerzo mide la carga cardiovascular. Convierte la frecuencia cardíaca en impulso de entrenamiento mediante zonas de reserva, dando más peso a las zonas intensas, y la sitúa en una escala logarítmica de 0 a 100. Los pasos y la energía activa cuentan en actividades largas con poco cardio. + La idea es similar al Day Strain de WHOOP (0–21), reescalado a 0–100. Un 100 sigue siendo tan excepcional como un 21,0. + Descanso: ¿qué tan reparador fue tu sueño? + El Descanso combina duración frente a tu necesidad, eficiencia, sueño profundo y REM reparador, y regularidad de las horas de dormir y despertar. + La idea es similar al Sleep Performance % de WHOOP, pero nuestro cálculo es independiente. + Sólido + En desarrollo + Cada puntuación indica su fiabilidad. «Calibrando» significa que NOOP aprende tu línea base o necesita más datos. «En desarrollo» indica que hay datos suficientes, pero aún limitados. «Sólido» indica que están presentes todos los datos. Si el cálculo no es fiable, NOOP no muestra una puntuación en vez de adivinar. Carga de entrenamiento CTL · Forma física ATL · Fatiga diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 42606413e7..bfc9bf5496 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1492,6 +1492,7 @@ %1$s%% Profil et réglages Récupération + à l\'instant en direct Synchronisation de l\'historique du bracelet, %1$d blocs Historique du bracelet synchronisé il y a %1$s @@ -2115,6 +2116,303 @@ Non recommandé comme défaut débutant sauf si le but est de vaincre la somnolence. Calmez-moi + + Charge / Effort / Repos + Démarrer une séance + Synthèse + Mesures clés + Entraînements + Fréquence cardiaque + Signes de récupération + Vos cartes + Cycle menstruel + Journal de bord + Cartes ajoutées + Aujourd’hui + 24 h + 12 h + 6 h + 3 h + 1 h + le plus récent + depuis le profil + Sur l’appareil + WHOOP + Apple Health + Google Health Connect + Xiaomi Mi Band + Alimentation + En cache + Apple Watch + Bague Oura + Bracelet Fitbit + Montre Garmin + Fichiers d’entraînement + " + " + Intensifiez + Maintenez + Reposez-vous + Aucune source pour le moment + Synchronisé depuis : %1$s + Redémarré lors du recalibrage du %1$s ; inutile d’appuyer à nouveau. + En cours ; portez-le cette nuit + En cours ; évolue avec votre activité + Comment %1$s est calculé + Comment ce score est calculé + Batterie du bracelet + Batterie du bracelet : %1$d pour cent + FIABLE + Score de Charge · %1$s + Récupération · %1$s + Afficher moins + Afficher toutes les mesures (%1$d) + Aucune donnée de fréquence cardiaque pour ce jour. Revenez à un jour où vous portiez le bracelet. + Aucune donnée de fréquence cardiaque au cours des dernières %1$s. Essayez une période plus large ou « Aujourd’hui ». + Étalonnage en cours. Aucune fréquence cardiaque enregistrée aujourd’hui. La courbe se complétera lorsque le bracelet transférera ses données. + Moyenne sur 5 minutes | dernières %1$s + Vue agrandie · faites glisser pour déplacer + Non connecté + Faut-il intensifier aujourd’hui ? + Une évaluation d’entraînement, distincte de votre score de Charge. + On dirait un entraînement vers %1$s–%2$s (FC moy. %3$d, %4$d min). L’enregistrer ? + Entraînement probable hier vers %1$s–%2$s (FC moy. %3$d, %4$d min). Enregistrer ? + Entraînement probable le %1$s vers %2$s–%3$s (FC moy. %4$d, %5$d min). Enregistrer ? + VFC + Variabilité de la fréquence cardiaque + FC au repos + Fréquence cardiaque au repos + Respiration + Respirations par minute + Oxygène sanguin + Oxygène sanguin + Température cutanée + Température cutanée + Sommeil + Nuit dernière + Pas + Aujourd’hui + Calories + Énergie active + Stress + Charge autonome + Âge fitness + Mise à jour hebdomadaire + VO₂ maximal + Estimation, mise à jour hebdomadaire + Vitalité + Score de bien-être + Hydratation + Hydratation du jour + Vue combinée + Récupération, effort et sommeil en un coup d’œil + Énergie + Effort + Repos + HRV + FC au repos + Oxygène sanguin + Respiration + Pas + Poids + Calories + Préparation + Portez le bracelet quelques nuits et votre lecture de préparation apparaîtra ici. + HRV + au-dessus de votre référence - bonne récupération + Dans votre plage habituelle + un peu sous la référence + affaiblie - signe de fatigue autonome + FC au repos + au niveau ou sous la référence + un peu élevée + élevée - le surentraînement ou une maladie peuvent en être la cause + Respiration + au-dessus de la référence - parfois un signe précoce de maladie + légèrement élevée par rapport à la référence + Variété d’entraînement + faible - une charge similaire chaque jour augmente le risque de surcharge ou de maladie + Charge d’entraînement + en baisse (aiguë:chronique %1$s) - marge pour progresser + dans la zone optimale (aiguë:chronique %1$s) + hausse rapide (aiguë:chronique %1$s) - surveillez la fatigue + pic (aiguë:chronique %1$s) - risque de blessure accru + Quelques nuits de données supplémentaires affineront votre lecture de préparation. + Épuisé + Plusieurs signaux sont faibles à la fois. Consacrez cette journée à la récupération : mouvement doux et vrai sommeil cette nuit. + Sous tension + Un de vos signaux donne l’alerte. Vous pouvez vous entraîner, mais gardez le contrôle et préservez la récupération. + Prêt + Vos signaux sont alignés et votre charge est soutenue. Une séance plus intense est bien appuyée aujourd’hui. + Équilibré + Aucun signal ne donne l’alerte. Entraînez-vous au ressenti : votre corps reste stable. + Changer de jour + Réduire + Afficher l’analyse complète + Voir toute votre préparation + Masquer les sources de données + Comment l’énergie est calculée + Réinitialiser le zoom de fréquence cardiaque + Voir ce qui a influencé %1$s + Afficher les sources synchronisées avec NOOP + Facteurs + Sources de données + Battements par minute + Aucune donnée + Provenance + 7 j %1$s / 28 j %2$s + %1$s contre %2$s %3$s + Monotonie %1$s + k=%1$s sur %2$d jours, confiance %3$s + Étalonnage : %1$d/%2$d jours + élevée + faible + moyenne + estim. · %1$s + estim. + k manuel=%1$s + L’énergie est faible. + L’énergie est faible et le sommeil était régulier. + L’énergie est faible et le sommeil a été trop court. + L’énergie est stable. + L’énergie est stable et le sommeil était régulier. + L’énergie est stable, mais le sommeil a été trop court. + L’énergie est forte. + L’énergie est forte et le sommeil était régulier. + L’énergie est forte, mais le sommeil a été trop court. + Épuisé + Faible + Pas encore de mesures. Importez votre export WHOOP ou portez le bracelet. + Pic + Stable + Fort + bpm + ms + resp./min + par rapport à votre référence + Facteurs de l’énergie + Ce qui a influencé + 14 jours + Activité + Étalonné manuellement + Estimation basée sur %1$d jours également comptés par le téléphone + Connectez le comptage de pas du téléphone pour estimer les pas + Encore %1$d jours également comptés par le téléphone + Création de votre référence, %1$d nuits sur %2$d. + Nuit dernière · %1$s + Min. + Moy. + Max. + Comptage… + %1$s jours · %2$s entraînements + Aujourd’hui · %1$s + Hier · %1$s + Aujourd’hui + Hier + Création de votre référence + L’énergie, l’effort et le repos deviennent personnels après quelques nuits. + En direct. Vos scores se construisent. + L’énergie, l’effort et le repos se construisent au fil des prochaines nuits. + Votre fréquence cardiaque en direct fonctionne déjà avec le bracelet. La récupération, l’effort et le sommeil se construisent au fil des prochaines nuits et gagnent en précision avec votre référence. Pour récupérer tout votre historique, importez votre export WHOOP dans Sources de données ; il sera ajouté en une minute environ. + Session en cours + Session terminée + Démarrer une session + Suivi actif : sans vibration, vous restez dans la zone. + Voir le résumé de votre dernière session. + Session d’effort guidée par le bracelet. Il vibre uniquement si vous quittez la zone du jour. + BÊTA + %1$s · nuit dernière + Choisissez les cartes affichées dans Aujourd’hui. Les cartes sans valeur affichent un tiret. + Chaque ligne indique de combien de points ce signal a modifié l’énergie par rapport à votre référence calculée sur l’appareil. Valeur approximative, pas un avis médical. + SOLIDE + EN COURS + ÉTALONNAGE + Moyenne sur 5 minutes | jour sélectionné + Moyenne sur 5 minutes | depuis minuit + Fréquence cardiaque sur 24 heures + plage de sommeil %1$s + Énergie au réveil : %1$d pour cent + Effort actuel : %1$s + , + Immobile + Marche + Course + Bonjour + Bon après-midi + Bonsoir + Étalonnage + %1$s · %2$s + Tendance sur 7 jours + Tendance sur 14 jours + Tendance sur 30 jours + 1 semaine + 2 semaines + 1 mois + %1$s %2$s. + + environ %1$d heure restante + environ %1$d heures restantes + + + environ %1$d jour restant + environ %1$d jours restants + + + %1$d nouveau jour d’historique est disponible dans Tendances. + %1$d nouveaux jours d’historique sont disponibles dans Tendances. + + + %1$d entraînement marqué + %1$d entraînements marqués + + + %1$s jour + %1$s jours + + + %1$s entraînement + %1$s entraînements + + Variabilité de la fréquence cardiaque + Fréquence cardiaque au repos + Qualité du sommeil + Fréquence respiratoire + Température cutanée + %1$d ms + %1$d bpm + %1$d%% + %1$s resp./min + %1$s °C par rapport à la référence + référence %1$d ms + référence %1$d bpm + référence %1$s resp./min + au-dessus de la référence, favorise la récupération + sous la référence, favorise la récupération + au-dessus de la référence, limite la récupération + sous la référence, limite la récupération + au niveau de la référence + sous la référence, limite la récupération ; la faible FC au repos suggère toutefois une saturation parasympathique plutôt que de la fatigue + une bonne nuit, favorise la récupération + sous le niveau d’une bonne nuit, limite la récupération + une nuit habituelle + proche de la référence + plus chaude que la référence, limite la récupération + plus froide que la référence, limite la récupération + %1$s, %2$s, %3$s, %4$s points, %5$s + Vos scores quotidiens + Les trois scores + NOOP propose trois scores quotidiens : Charge, Effort et Repos, de 0 à 100. Ils sont calculés sur votre appareil à partir des signaux du bracelet et de travaux publiés en sciences du sport. Ce sont des estimations indépendantes, pas les scores privés de WHOOP ; ils peuvent donc évoluer de façon similaire sans coïncider. + Charge : quel est votre niveau de récupération ? + La Charge dépend surtout de votre VFC comparée à votre référence personnelle, ainsi que de la fréquence cardiaque au repos, du Repos de la nuit dernière, de la respiration et de la température cutanée. Une VFC plus élevée donne davantage de Charge. NOOP a besoin de quelques nuits pour apprendre votre référence ; entre-temps, il affiche « Calibration en cours ». + L’idée est proche du Recovery % de WHOOP, mais notre pondération et notre référence sont indépendantes et documentées. + Effort : à quel point votre cœur a-t-il travaillé ? + L’Effort mesure la charge cardiovasculaire. La fréquence cardiaque devient une impulsion d’entraînement selon les zones de réserve ; les zones intenses pèsent davantage. Le résultat suit une échelle logarithmique de 0 à 100. Les pas et l’énergie active comptent pour les activités longues avec peu de cardio. + L’idée est proche du Day Strain de WHOOP (0–21), remis à l’échelle de 0 à 100. Un 100 reste aussi rare qu’un 21,0. + Repos : votre sommeil a-t-il été réparateur ? + Le Repos combine la durée par rapport à vos besoins, l’efficacité, le sommeil profond et paradoxal réparateur, et la régularité des heures de coucher et de réveil. + L’idée est proche du Sleep Performance % de WHOOP, mais notre calcul est indépendant. + Fiable + En cours + Chaque score indique sa fiabilité. « Calibration en cours » signifie que NOOP apprend votre référence ou manque de données. « En cours » signifie que les données suffisent mais restent limitées. « Fiable » signifie que toutes les entrées sont présentes. Si le calcul n’est pas fiable, NOOP n’affiche rien plutôt que d’inventer. Charge d’entraînement CTL · Forme ATL · Fatigue diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 985d66f1b2..338566cefc 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -1630,10 +1630,11 @@ Przesuń %1$s w dół Przesuń %1$s w górę Dodano nowe dane - Nie ma jeszcze obciążenia kardio. Wysiłek zwiększa się, gdy tętno osiągnie strefę wysiłku (około 50% of rezerwy tętna). Spokojny dzień szczerze odczytuje się w pobliżu zera. + Nie ma jeszcze obciążenia kardio. Wysiłek zwiększa się, gdy tętno osiągnie strefę wysiłku (około 50% rezerwy tętna). Spokojny dzień szczerze odczytuje się w pobliżu zera. %1$s%% Profil i ustawienia Regeneracja + Teraz na żywo Synchronizowanie historii pasków, fragmenty %1$d Historia paska zsynchronizowana %1$s temu @@ -2109,6 +2110,315 @@ Niezalecane jako domyślne dla początkujących, chyba że celem jest przebicie senności. Uspokój mnie + + Energia / Wysiłek / Odpoczynek + Rozpocznij sesję + Podsumowanie + Kluczowe wskaźniki + Treningi + Tętno + Wskaźniki regeneracji + Twoje karty + Cykl menstruacyjny + Dziennik + Dodane karty + Dzisiaj + 24 godz. + 12 godz. + 6 godz. + 3 godz. + 1 godz. + najnowszy + z profilu + Na urządzeniu + WHOOP + Apple Health + Google Health Connect + Xiaomi Mi Band + Odżywianie + Z pamięci podręcznej + Apple Watch + Pierścień Oura + Opaska Fitbit + Zegarek Garmin + Pliki treningów + " + " + Działaj + Utrzymaj + Odpocznij + Brak źródeł + Zsynchronizowano z: %1$s + Uruchomiono ponownie po kalibracji z dnia %1$s — nie musisz naciskać ponownie. + W trakcie tworzenia — załóż opaskę na noc + W trakcie tworzenia — zmienia się wraz z aktywnością + Jak obliczany jest wynik %1$s + Jak obliczany jest ten wynik + Bateria opaski + Bateria opaski: %1$d procent + PEWNY + Energia · %1$s + Regeneracja · %1$s + Pokaż mniej + Pokaż wszystkie wskaźniki (%1$d) + Brak danych tętna dla tego dnia. Wróć do dnia, w którym opaska była noszona. + Brak danych tętna z ostatnich %1$s. Wybierz dłuższy okres lub „Dzisiaj”. + Trwa kalibracja. Dzisiaj nie zapisano jeszcze tętna. Wykres wypełni się po przesłaniu danych z opaski. + Średnia 5-minutowa | ostatnie %1$s + Powiększono · przeciągnij, aby przesunąć + Brak połączenia + Czy dziś warto dać z siebie więcej? + Ocena treningowa niezależna od wyniku Energii. + Wygląda na trening około %1$s–%2$s (śr. tętno %3$d, %4$d min). Zapisać? + Wygląda na wczorajszy trening około %1$s–%2$s (śr. tętno %3$d, %4$d min). Zapisać? + Wygląda na trening %1$s około %2$s–%3$s (śr. tętno %4$d, %5$d min). Zapisać? + Zmienność rytmu serca + Zmienność rytmu serca + Tętno spoczynkowe + Tętno spoczynkowe + Oddech + Oddechy na minutę + Tlen we krwi + Tlen we krwi + Temperatura skóry + Temperatura skóry + Sen + Ostatnia noc + Kroki + Dzisiaj + Kalorie + Energia aktywna + Stres + Obciążenie autonomiczne + Wiek sprawnościowy + Aktualizacja co tydzień + Maksymalne VO₂ + Wartość szacowana, aktualizowana co tydzień + Witalność + Wynik dobrostanu + Nawodnienie + Dzisiejsze nawodnienie + Widok łączony + Regeneracja, wysiłek i sen w jednym miejscu + Energia + Wysiłek + Odpoczynek + HRV + Tętno spoczynkowe + Tlen we krwi + Oddech + Kroki + Masa ciała + Kalorie + Gotowość + Noś opaskę przez kilka nocy, aby zbudować swoją bazę. + HRV + powyżej bazy - dobra regeneracja + W twoim normalnym zakresie + nieco poniżej bazy + obniżona - oznaka zmęczenia autonomicznego + Tętno spoczynkowe + na poziomie bazy lub poniżej + nieco podwyższone + podwyższone - przyczyną może być przetrenowanie lub choroba + Oddech + powyżej bazy - czasem wczesny sygnał choroby + lekko podwyższona względem bazy + Różnorodność treningu + niska - podobny wysiłek każdego dnia zwiększa ryzyko przeciążenia lub choroby + Obciążenie treningowe + spadek (ostra:przewlekła %1$s) — jest przestrzeń na wzrost + w optymalnym zakresie (ostra:przewlekła %1$s) + szybki wzrost (ostra:przewlekła %1$s) — obserwuj zmęczenie + skok (ostra:przewlekła %1$s) — większe ryzyko urazu + Kilka kolejnych nocy zwiększy wiarygodność odczytu. + Wyczerpanie + Kilka sygnałów jest jednocześnie obniżonych. Potraktuj dziś jako regenerację: lekki ruch i porządny sen. + Przeciążenie + Jeden z sygnałów ostrzega. Możesz trenować, ale zachowaj kontrolę i zadbaj o regenerację. + Gotowość + Sygnały są zgodne, a obciążenie ma dobre wsparcie. Mocniejsza sesja jest dziś dobrze uzasadniona. + Równowaga + Żaden sygnał nie ostrzega. Trenuj według samopoczucia - organizm jest stabilny. + Zmień dzień + Zwiń + Rozwiń pełny odczyt + Zobacz pełną gotowość + Ukryj szczegóły źródeł + Jak obliczana jest energia + Zresetuj powiększenie tętna + Zobacz, co wpłynęło na %1$s + Pokaż źródła synchronizowane z NOOP + Czynniki + Źródła danych + Uderzenia na minutę + Brak danych + Pochodzenie + 7 dni %1$s / 28 dni %2$s + %1$s wobec %2$s %3$s + Monotonia %1$s + k=%1$s z %2$d dni, pewność %3$s + Kalibracja: %1$d/%2$d dni + wysoka + niska + średnia + szac. · %1$s + szac. + ręczne k=%1$s + Energia jest niska. + Energia jest niska, a sen był regularny. + Energia jest niska, a sen był za krótki. + Energia jest stabilna. + Energia jest stabilna, a sen był regularny. + Energia jest stabilna, ale sen był za krótki. + Energia jest wysoka. + Energia jest wysoka, a sen był regularny. + Energia jest wysoka, ale sen był za krótki. + Wyczerpanie + Niski + Brak pomiarów. Zaimportuj dane WHOOP lub zacznij nosić opaskę. + Szczyt + Stabilnie + Silny + ud./min + ms + oddechów/min + wobec twojej bazy + Co wpłynęło na energię + Co miało wpływ + 14 dni + Aktywność + Skalibrowano ręcznie + Oszacowano na podstawie %1$d dni z krokami telefonu + Połącz kroki z telefonu, aby je oszacować + Potrzeba jeszcze %1$d dni z krokami telefonu + Tworzenie bazy: %1$d z %2$d nocy. + Ostatnia noc · %1$s + Min. + Śr. + Maks. + Zliczanie… + %1$s dni · %2$s treningów + Dzisiaj · %1$s + Wczoraj · %1$s + Dzisiaj + Wczoraj + Tworzenie twojej bazy + Energia, wysiłek i odpoczynek stają się osobiste po kilku nocach noszenia. + Pomiar działa. Wyniki są tworzone. + Energia, wysiłek i odpoczynek powstaną w ciągu kilku kolejnych nocy. + Tętno na żywo działa już z opaski. Regeneracja, wysiłek i sen będą tworzone przez kolejne noce i staną się dokładniejsze wraz z bazą. Aby od razu uzyskać całą historię, zaimportuj eksport WHOOP w Źródłach danych. + Sesja trwa + Sesja zakończona + Rozpocznij sesję + Monitorowanie trwa — brak wibracji oznacza właściwy zakres. + Zobacz podsumowanie ostatniej sesji. + Sesja wysiłkowa prowadzona przez opaskę. Wibruje tylko po wyjściu z dzisiejszego zakresu. + BETA + %1$s · ostatnia noc + Wybierz karty widoczne na ekranie Dzisiaj. Karty bez wartości pokazują kreskę. + Każdy wiersz pokazuje, o ile punktów sygnał zmienił energię względem bazy obliczonej na urządzeniu. To przybliżenie, nie porada medyczna. + PEWNY + W BUDOWIE + KALIBRACJA + Średnia 5-minutowa | wybrany dzień + Średnia 5-minutowa | od północy + Tętno z 24 godzin + pasmo snu %1$s + Energia po przebudzeniu: %1$d procent + Aktualny wysiłek: %1$s + , + Bez ruchu + Chód + Bieg + Dzień dobry + Dzień dobry + Dobry wieczór + Kalibracja + %1$s · %2$s + Trend 7-dniowy + Trend 14-dniowy + Trend 30-dniowy + 1 tydzień + 2 tygodnie + 1 miesiąc + %1$s %2$s. + + pozostała około %1$d godzina + pozostały około %1$d godziny + pozostało około %1$d godzin + pozostało około %1$d godziny + + + pozostał około %1$d dzień + pozostały około %1$d dni + pozostało około %1$d dni + pozostało około %1$d dnia + + + %1$d nowy dzień historii jest gotowy w Trendach. + %1$d nowe dni historii są gotowe w Trendach. + %1$d nowych dni historii jest gotowych w Trendach. + %1$d dnia historii jest gotowe w Trendach. + + + oznaczono %1$d trening + oznaczono %1$d treningi + oznaczono %1$d treningów + oznaczono %1$d treningu + + + %1$s dzień + %1$s dni + %1$s dni + %1$s dnia + + + %1$s trening + %1$s treningi + %1$s treningów + %1$s treningu + + Zmienność rytmu serca + Tętno spoczynkowe + Jakość snu + Częstość oddechu + Temperatura skóry + %1$d ms + %1$d ud./min + %1$d%% + %1$s oddechów/min + %1$s °C względem bazy + baza %1$d ms + baza %1$d ud./min + baza %1$s oddechów/min + powyżej bazy, wspiera regenerację + poniżej bazy, wspiera regenerację + powyżej bazy, ogranicza regenerację + poniżej bazy, ogranicza regenerację + na poziomie bazy + poniżej bazy, ogranicza regenerację; niskie tętno spoczynkowe może jednak wskazywać na nasycenie przywspółczulne zamiast zmęczenia + mocna noc, wspiera regenerację + poniżej dobrej nocy, ogranicza regenerację + typowa noc + blisko bazy + cieplej niż baza, ogranicza regenerację + chłodniej niż baza, ogranicza regenerację + %1$s, %2$s, %3$s, %4$s pkt, %5$s + Twoje dzienne wyniki + Trzy wyniki + NOOP podaje trzy dzienne wyniki: Energię, Wysiłek i Odpoczynek, od 0 do 100. Są obliczane na urządzeniu z sygnałów opaski i opublikowanej nauki o sporcie. To niezależne oszacowania, a nie prywatne wyniki WHOOP, dlatego mogą zmieniać się podobnie, lecz nie będą identyczne. + Energia: jak dobrze się zregenerowałeś? + Energia opiera się głównie na HRV względem osobistej wartości bazowej oraz tętnie spoczynkowym, Odpoczynku z ostatniej nocy, oddechu i temperaturze skóry. Wyższe HRV oznacza więcej Energii. NOOP potrzebuje kilku nocy na poznanie wartości bazowej; do tego czasu wyświetla „Kalibracja”. + Idea jest podobna do Recovery % WHOOP, ale nasze wagi i obliczenia wartości bazowej są niezależne i udokumentowane. + Wysiłek: jak ciężko pracowało Twoje serce? + Wysiłek mierzy obciążenie układu krążenia. Tętno jest przeliczane na impuls treningowy za pomocą stref rezerwy; intensywne strefy ważą więcej. Wynik trafia na logarytmiczną skalę 0–100. Kroki i aktywna energia uwzględniają długie aktywności o małym obciążeniu. + Idea jest podobna do Day Strain WHOOP (0–21), przeskalowanego do 0–100. Wynik 100 pozostaje tak rzadki jak 21,0. + Odpoczynek: jak regenerujący był Twój sen? + Odpoczynek łączy długość snu względem potrzeb, wydajność snu, regeneracyjny sen głęboki i REM oraz regularność pór snu i budzenia. + Idea jest podobna do Sleep Performance % WHOOP, ale nasze obliczenie jest niezależne. + Pewny + W trakcie + Każdy wynik pokazuje wiarygodność. „Kalibracja” oznacza naukę wartości bazowej lub brak danych. „W trakcie” oznacza, że danych wystarcza, ale nadal jest ich niewiele. „Pewny” oznacza komplet danych. Gdy obliczenie nie jest wiarygodne, NOOP nie pokazuje wyniku zamiast zgadywać. Obciążenie treningowe CTL · Kondycja ATL · Zmęczenie diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index e3b1dfd503..4c4258a462 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1640,6 +1640,7 @@ %1$s%% Perfil e definições Recuperação + agora em direto A sincronizar o histórico da bracelete, %1$d pedaços Histórico da bracelete sincronizado há %1$s @@ -1657,7 +1658,7 @@ Passos Esforço Sincronizado de - SYNTHESIS + SÍNTESE %1$s Esforço %1$s bpm %1$s @@ -2109,6 +2110,303 @@ Não recomendado como padrão para principiantes, salvo se o objetivo for ultrapassar a sonolência. Acalma-me + + Carga / Esforço / Descanso + Iniciar sessão + Síntese + Métricas principais + Treinos + Frequência cardíaca + Sinais de recuperação + Os teus cartões + Ciclo menstrual + Diário + Cartões adicionados + Hoje + 24 h + 12 h + 6 h + 3 h + 1 h + mais recente + do perfil + No dispositivo + WHOOP + Apple Health + Google Health Connect + Xiaomi Mi Band + Nutrição + Em cache + Apple Watch + Anel Oura + Dispositivo Fitbit + Relógio Garmin + Ficheiros de treino + " + " + Força + Mantém + Descansa + Ainda não há fontes + Sincronizado de: %1$s + Reiniciado quando recalibraste em %1$s; não precisas de tocar novamente. + Em preparação; usa-a esta noite + Em preparação; evolui com a tua atividade + Como é calculado %1$s + Como é calculada esta pontuação + Bateria da pulseira + Bateria da pulseira: %1$d por cento + FIÁVEL + Carga · %1$s + Recuperação · %1$s + Mostrar menos + Mostrar todas as métricas (%1$d) + Não há dados de frequência cardíaca para este dia. Recua até um dia em que tenhas usado a pulseira. + Não há dados de frequência cardíaca nas últimas %1$s. Experimenta um período maior ou «Hoje». + A calibrar. Ainda não foram guardados dados de frequência cardíaca hoje. A curva será preenchida quando a pulseira transferir os dados. + Média de 5 minutos | últimas %1$s + Vista ampliada · arrasta para deslocar + Não ligado + Deves esforçar-te hoje? + Uma avaliação de treino, independente da tua pontuação de Carga. + Parece ter sido um treino entre as %1$s e as %2$s (FC méd. %3$d, %4$d min). Guardar? + Treino provável ontem entre as %1$s e as %2$s (FC méd. %3$d, %4$d min). Guardar? + Treino provável em %1$s entre as %2$s e as %3$s (FC méd. %4$d, %5$d min). Guardar? + VFC + Variabilidade da frequência cardíaca + FC em repouso + Frequência cardíaca em repouso + Respiração + Respirações por minuto + Oxigénio no sangue + Oxigénio no sangue + Temperatura da pele + Temperatura da pele + Sono + Noite passada + Passos + Hoje + Calorias + Energia ativa + Stress + Carga autonómica + Idade de fitness + Atualização semanal + VO₂ máximo + Estimado, atualizado semanalmente + Vitalidade + Pontuação de bem-estar + Hidratação + Líquidos de hoje + Vista combinada + Recuperação, esforço e sono num relance + Energia + Esforço + Descanso + HRV + FC em repouso + Oxigénio no sangue + Respiração + Passos + Peso + Calorias + Prontidão + Use a pulseira durante algumas noites e a sua leitura de prontidão aparecerá aqui. + HRV + acima da sua referência - boa recuperação + Dentro do seu intervalo normal + ligeiramente abaixo da referência + suprimida - um sinal de fadiga autonómica + FC em repouso + na referência ou abaixo + um pouco elevada + elevada - o excesso de treino ou uma doença podem causar isto + Respiração + acima da referência - por vezes, um sinal precoce de doença + ligeiramente elevada face à referência + Variedade de treino + baixa - esforço semelhante todos os dias aumenta o risco de sobrecarga ou doença + Carga de treino + a diminuir (aguda:crónica %1$s) - margem para aumentar + na zona ideal (aguda:crónica %1$s) + a aumentar rapidamente (aguda:crónica %1$s) - esteja atento à fadiga + pico (aguda:crónica %1$s) - maior risco de lesão + Mais algumas noites de dados irão tornar a sua leitura de prontidão mais precisa. + Esgotado + Vários sinais estão baixos ao mesmo tempo. Trate hoje como recuperação: movimento leve e sono reparador esta noite. + Sobrecarregado + Um dos seus sinais está a dar alerta. Pode treinar, mas mantenha o controlo e preserve a recuperação. + Preparado + Os seus sinais estão alinhados e a sua carga tem suporte. Uma sessão mais exigente está bem sustentada hoje. + Equilibrado + Nenhum sinal está a dar alerta. Treine de acordo com as sensações: o seu corpo mantém-se estável. + Alterar dia + Recolher + Abrir leitura completa + Ver toda a prontidão + Ocultar fontes de dados + Como a energia é calculada + Repor zoom da frequência cardíaca + Ver o que influenciou %1$s + Mostrar as fontes sincronizadas com o NOOP + Contributos + Fontes de dados + Batimentos por minuto + Sem dados + Proveniência + 7 d %1$s / 28 d %2$s + %1$s vs. %2$s %3$s + Monotonia %1$s + k=%1$s de %2$d dias, confiança %3$s + A calibrar: %1$d/%2$d dias + alta + baixa + média + estim. · %1$s + estim. + k manual=%1$s + A energia está baixa. + A energia está baixa e o sono foi consistente. + A energia está baixa e o sono foi curto. + A energia está estável. + A energia está estável e o sono foi consistente. + A energia está estável, mas o sono foi curto. + A energia está forte. + A energia está forte e o sono foi consistente. + A energia está forte, mas o sono foi curto. + Esgotado + Baixa + Ainda não existem métricas. Importe a exportação WHOOP ou use a pulseira. + Pico + Estável + Forte + bpm + ms + resp./min + vs. referência + O que influenciou a energia + O que influenciou + 14 dias + Atividade + Calibrado manualmente + Estimado com %1$d dias também contados pelo telemóvel + Ligue a contagem de passos do telemóvel para estimar passos + Faltam %1$d dias também contados pelo telemóvel + A criar a sua referência, %1$d de %2$d noites. + Noite passada · %1$s + Mín. + Méd. + Máx. + A contar… + %1$s dias · %2$s treinos + Hoje · %1$s + Ontem · %1$s + Hoje + Ontem + A criar a sua referência + A energia, o esforço e o descanso tornam-se pessoais após algumas noites de utilização. + Em direto. As suas pontuações estão a ser criadas. + A energia, o esforço e o descanso são criados ao longo das próximas noites. + A frequência cardíaca em direto já funciona através da pulseira. A recuperação, o esforço e o sono são criados ao longo das próximas noites e melhoram à medida que a sua referência é aprendida. Para obter já todo o histórico, importe a exportação WHOOP em Fontes de dados; o preenchimento demora cerca de um minuto. + Sessão em curso + Sessão terminada + Iniciar sessão + Monitorização ativa: se não vibrar, está dentro do intervalo. + Ver o resumo da sua última sessão. + Sessão de esforço guiada pela pulseira. Só vibra quando sai do intervalo de hoje. + BETA + %1$s · noite passada + Escolha os cartões apresentados em Hoje. Os cartões sem valor mostram um traço. + Cada linha mostra quantos pontos esse sinal alterou a energia face à sua referência calculada no dispositivo. Valor aproximado, não constitui aconselhamento médico. + SÓLIDA + EM CONSTRUÇÃO + A CALIBRAR + Média de 5 minutos | dia selecionado + Média de 5 minutos | desde a meia-noite + Frequência cardíaca de 24 horas + faixa de sono %1$s + Energia ao acordar: %1$d por cento + Esforço atual: %1$s + , + Parado + A caminhar + A correr + Bom dia + Boa tarde + Boa noite + A calibrar + %1$s · %2$s + Tendência de 7 dias + Tendência de 14 dias + Tendência de 30 dias + 1 semana + 2 semanas + 1 mês + %1$s %2$s. + + resta cerca de %1$d hora + restam cerca de %1$d horas + + + resta cerca de %1$d dia + restam cerca de %1$d dias + + + %1$d novo dia de histórico está disponível em Tendências. + %1$d novos dias de histórico estão disponíveis em Tendências. + + + %1$d treino marcado + %1$d treinos marcados + + + %1$s dia + %1$s dias + + + %1$s treino + %1$s treinos + + Variabilidade da frequência cardíaca + Frequência cardíaca em repouso + Qualidade do sono + Frequência respiratória + Temperatura da pele + %1$d ms + %1$d bpm + %1$d%% + %1$s resp./min + %1$s °C face à referência + referência de %1$d ms + referência de %1$d bpm + referência de %1$s resp./min + acima da referência, favorece a recuperação + abaixo da referência, favorece a recuperação + acima da referência, limita a recuperação + abaixo da referência, limita a recuperação + na referência + abaixo da referência, limita a recuperação; a FC em repouso baixa sugere que poderá ser saturação parassimpática e não fadiga + uma noite forte, favorece a recuperação + abaixo de uma boa noite, limita a recuperação + uma noite típica + perto da referência + mais quente do que a referência, limita a recuperação + mais fria do que a referência, limita a recuperação + %1$s, %2$s, %3$s, %4$s pontos, %5$s + As tuas pontuações diárias + As três pontuações + O NOOP apresenta três pontuações diárias: Energia, Esforço e Descanso, de 0 a 100. São calculadas no dispositivo a partir dos sinais da bracelete e de ciência desportiva publicada. São estimativas independentes, não as pontuações privadas do WHOOP, pelo que podem evoluir de forma semelhante sem coincidirem. + Energia: quão recuperado estás? + A Energia baseia-se sobretudo na VFC face à tua linha de base pessoal, além da frequência cardíaca em repouso, do Descanso da noite anterior, da respiração e da temperatura da pele. Uma VFC mais alta significa mais Energia. O NOOP precisa de algumas noites para aprender a linha de base; até lá mostra «A calibrar». + A ideia é semelhante à Recovery % do WHOOP, mas a nossa ponderação e linha de base são independentes e estão documentadas. + Esforço: quanto trabalhou o teu coração? + O Esforço mede a carga cardiovascular. A frequência cardíaca é convertida num impulso de treino através das zonas de reserva, com maior peso nas zonas intensas, e colocada numa escala logarítmica de 0 a 100. Os passos e a energia ativa contam para atividades longas com pouco cardio. + A ideia é semelhante ao Day Strain do WHOOP (0–21), redimensionado para 0–100. Um 100 continua tão raro como um 21,0. + Descanso: quão reparador foi o teu sono? + O Descanso combina a duração face à tua necessidade, a eficiência, o sono profundo e REM reparador e a regularidade das horas de adormecer e acordar. + A ideia é semelhante à Sleep Performance % do WHOOP, mas o nosso cálculo é independente. + Sólido + Em formação + Cada pontuação indica a fiabilidade. «A calibrar» significa que o NOOP aprende a tua linha de base ou precisa de mais dados. «Em formação» indica dados suficientes, mas ainda limitados. «Sólido» indica todos os dados presentes. Se o cálculo não for fiável, o NOOP não mostra uma pontuação em vez de adivinhar. Carga de treino CTL · Forma física ATL · Fadiga diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index b7cd60e5f4..88252f8de9 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1608,7 +1608,8 @@ 将此卡片收起至“更新”中 每一条线都代表该项体征导致您的充能(Charge)总分偏离了多少分(与 编辑 - Edit Key Metrics + 编辑关键指标 + 你的手环已有 %1$d 天没有记录到新的夜间数据。请检查它是否已连接并正在保存数据。 心率变异性(HRV) %1$s 是如何计算出来的 %1$s bpm (次/分钟) @@ -1620,6 +1621,7 @@ %1$s%% 个人资料与设置 恢复分数 + 刚刚 实时 正在同步腕带历史记录,%1$d 个数据块 手环历史记录已在 %1$s 前同步 @@ -2043,6 +2045,297 @@ 除非目标是突破困倦,否则不建议作为初学者默认。 让我平静 + + 充能 / 负荷 / 恢复 + 开始训练 + 综合分析 + 关键指标 + 训练 + 心率 + 恢复体征 + 你的卡片 + 月经周期 + 日志 + 已添加的卡片 + 今天 + 24 小时 + 12 小时 + 6 小时 + 3 小时 + 1 小时 + 最新 + 来自个人资料 + 设备端 + WHOOP + Apple 健康 + Google 健康数据同步 + 小米手环 + 营养 + 缓存 + Apple Watch 手表 + Oura 戒指 + Fitbit 设备 + Garmin 手表 + 训练文件 + " + " + 加大强度 + 保持 + 休息 + 暂无数据源 + 同步自:%1$s + 已在 %1$s 重新校准后重启,无需再次点击。 + 正在建立,今晚请佩戴 + 正在建立,会随活动更新 + %1$s 的计算方式 + 此分数的计算方式 + 手环电量 + 手环电量:%1$d%% + 可靠 + 充能 · %1$s + 恢复 · %1$s + 收起 + 显示全部指标(%1$d) + 当天没有心率数据。请返回到佩戴手环的日期。 + 过去 %1$s 内没有心率数据。请尝试更长的时间范围或选择“今天”。 + 正在校准。今天尚未保存心率数据,手环传输数据后曲线会逐步显示。 + 5 分钟平均值 | 过去 %1$s + 已放大 · 拖动平移 + 未连接 + 今天适合提高强度吗? + 这是训练状态评估,与充能分数相互独立。 + 检测到可能的训练:%1$s–%2$s(平均心率 %3$d,%4$d 分钟)。保存吗? + 检测到昨天可能的训练:%1$s–%2$s(平均心率 %3$d,%4$d 分钟)。保存吗? + 检测到 %1$s 可能的训练:%2$s–%3$s(平均心率 %4$d,%5$d 分钟)。保存吗? + 心率变异性 + 心率变异性 + 静息心率 + 静息心率 + 呼吸频率 + 每分钟呼吸次数 + 血氧 + 血氧 + 皮肤温度 + 皮肤温度 + 睡眠 + 昨晚 + 步数 + 今天 + 卡路里 + 活动能量 + 压力 + 自主神经负荷 + 体能年龄 + 每周更新 + 最大摄氧量 + 估算值,每周更新 + 活力 + 健康状态评分 + 补水 + 今日补水 + 综合视图 + 一览恢复、负荷与睡眠 + 能量 + 负荷 + 休息 + HRV + 静息心率 + 血氧 + 呼吸频率 + 步数 + 体重 + 卡路里 + 准备度 + 佩戴腕带数晚以建立个人基线。 + HRV + 高于基线 - 恢复良好 + 处于正常范围 + 略低于基线 + 受到抑制 - 自主神经疲劳的迹象 + 静息心率 + 处于或低于基线 + 略微偏高 + 升高 - 过度训练或生病都可能导致这种情况 + 呼吸频率 + 高于基线 - 有时是生病的早期迹象 + 较基线略有升高 + 训练多样性 + 偏低 - 每天相似的负荷会增加过度负荷或生病风险 + 训练负荷 + 负荷下降(急性:慢性 %1$s)— 尚有提升空间 + 处于理想区间(急性:慢性 %1$s) + 快速上升(急性:慢性 %1$s)— 留意疲劳 + 负荷激增(急性:慢性 %1$s)— 受伤风险更高 + 再记录几晚可提高解读可靠性。 + 疲惫 + 多项信号同时下降。今天应以恢复为主:轻度活动,并在今晚保证充足睡眠。 + 压力偏高 + 有一项信号发出警示。可以训练,但请控制强度并留出恢复空间。 + 状态良好 + 各项信号协调,负荷得到良好支撑。今天进行更高强度训练也有充分依据。 + 平衡 + 没有信号发出警示。按身体感受训练;你的状态保持稳定。 + 更改日期 + 收起 + 展开完整解读 + 查看完整准备度 + 隐藏数据源详情 + 了解能量的计算方式 + 重置心率缩放 + 查看影响 %1$s 的因素 + 查看 NOOP 同步的数据源 + 影响因素 + 数据源 + 每分钟心跳 + 暂无数据 + 数据来源 + 7 天 %1$s / 28 天 %2$s + %1$s,对比基线 %2$s %3$s + 单调度 %1$s + k=%1$s,基于 %2$d 天,置信度%3$s + 校准中:%1$d/%2$d 天 + 高 + 低 + 中 + 估算 · %1$s + 估算 + 手动 k=%1$s + 能量偏低。 + 能量偏低,睡眠较稳定。 + 能量偏低,且睡眠不足。 + 能量稳定。 + 能量稳定,睡眠也较规律。 + 能量稳定,但睡眠不足。 + 能量充足。 + 能量充足,睡眠也较规律。 + 能量充足,但睡眠不足。 + 耗尽 + 偏低 + 尚无指标。请导入 WHOOP 数据或佩戴腕带开始记录。 + 巅峰 + 稳定 + 强劲 + 次/分 + 毫秒 + 次呼吸/分 + 与你的基线相比 + 能量影响因素 + 影响因素 + 14 天 + 活动 + 手动校准 + 根据手机同时记录的 %1$d 天估算 + 连接手机步数以估算步数 + 还需 %1$d 天由手机同时记录步数 + 正在建立基线:%1$d/%2$d 晚。 + 昨晚 · %1$s + 最低 + 平均 + 最高 + 正在统计… + %1$s 天 · %2$s 次训练 + 今天 · %1$s + 昨天 · %1$s + 今天 + 昨天 + 正在建立个人基线 + 佩戴数晚后,能量、负荷和休息将更贴合你的情况。 + 实时数据正常,评分正在建立。 + 能量、负荷和休息将在接下来的几晚逐步建立。 + 腕带的实时心率已正常工作。恢复、负荷和睡眠会在接下来的几晚逐步建立,并随着个人基线形成而更准确。如需立即补齐历史记录,请在“数据源”中导入 WHOOP 导出文件,通常约一分钟即可完成。 + 训练进行中 + 训练已结束 + 开始训练 + 正在守护:没有振动表示你仍在目标区间。 + 查看上一次训练的总结。 + 由腕带引导的负荷训练。只有偏离今天的目标区间时才会振动。 + 测试版 + %1$s · 昨晚 + 选择“今天”中显示的卡片。暂无数值的卡片会显示短横线。 + 每一行表示该信号相对于设备端个人基线使能量变化了多少分。仅供参考,不构成医疗建议。 + 稳定 + 建立中 + 校准中 + 5 分钟平均值|所选日期 + 5 分钟平均值|自午夜起 + 24 小时心率 + 睡眠区间 %1$s + 醒来时能量 %1$d%% + 当前负荷 %1$s + , + 静止 + 步行 + 跑步 + 早上好 + 下午好 + 晚上好 + 校准中 + %1$s · %2$s + 7 天趋势 + 14 天趋势 + 30 天趋势 + 1 周 + 2 周 + 1 个月 + %1$s %2$s。 + + 约剩 %1$d 小时 + + + 约剩 %1$d 天 + + + 趋势中已有 %1$d 天的新历史记录。 + + + 已标记 %1$d 次训练 + + + %1$s 天 + + + %1$s 次训练 + + 心率变异性 + 静息心率 + 睡眠质量 + 呼吸频率 + 皮肤温度 + %1$d 毫秒 + %1$d 次/分 + %1$d%% + %1$s 次呼吸/分 + 相对基线 %1$s°C + 基线 %1$d 毫秒 + 基线 %1$d 次/分 + 基线 %1$s 次呼吸/分 + 高于基线,有利于恢复 + 低于基线,有利于恢复 + 高于基线,限制恢复 + 低于基线,限制恢复 + 处于基线 + 低于基线,限制恢复;但较低的静息心率提示这可能是副交感神经饱和,而非疲劳 + 状态良好的一晚,有利于恢复 + 低于良好夜晚水平,限制恢复 + 典型的一晚 + 接近基线 + 高于基线温度,限制恢复 + 低于基线温度,限制恢复 + %1$s,%2$s,%3$s,%4$s 分,%5$s + 你的每日评分 + 三项评分 + NOOP 每天提供三项评分:能量、负荷和休息,均为 0–100 分。评分依据手环信号和已发表的运动科学在设备上计算。它们是独立估算,并非 WHOOP 的私有评分,因此趋势可能相似,但数值不会完全一致。 + 能量:你的恢复情况如何? + 能量主要依据 HRV 与个人基线的对比,同时考虑静息心率、昨晚的休息、呼吸和皮肤温度。HRV 越高,能量越高。NOOP 需要几个夜晚学习基线;在此之前会显示“校准中”。 + 理念与 WHOOP 的 Recovery % 相似,但权重和基线计算由我们独立设计并公开说明。 + 负荷:你的心脏工作得有多辛苦? + 负荷衡量心血管压力。心率通过心率储备区间转化为训练冲量,高强度区间权重更大,再映射到 0–100 的对数刻度。步数和活动能量会让长时间低心肺负荷活动也计入。 + 理念与 WHOOP 的 Day Strain(0–21)相似,并换算为 0–100。100 分与过去的 21.0 一样罕见。 + 休息:你的睡眠恢复效果如何? + 休息综合睡眠时长与个人需求、睡眠效率、恢复性深睡和 REM,以及入睡和起床时间的规律性。 + 理念与 WHOOP 的 Sleep Performance % 相似,但计算方法由我们独立设计。 + 可靠 + 建立中 + 每项评分都会显示可靠性。“校准中”表示 NOOP 正在学习基线或需要更多数据;“建立中”表示数据足以评分但仍有限;“可靠”表示输入齐全。如果无法可靠计算,NOOP 会留空而不是猜测。 移除此次睡眠并重新计算当天数据。之后几秒内可以撤销。 移除此次已记录的睡眠并重新计算当天数据。NOOP 不会在此时间段内重新检测睡眠。之后几秒内可以撤销。 可选 · VO₂max 由约 4 晚的心率数据构建;腰围可让它更准确。健康年龄本身并不需要它。请在肚脐高度绕腰一周测量。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e3459f8d99..b9550d6763 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1686,6 +1686,7 @@ %1$s%% Profile and settings Recovery + now live Syncing strap history, %1$d chunks Strap history synced %1$s ago @@ -2142,6 +2143,303 @@ Not recommended as a beginner default unless the goal is to overcome drowsiness. Calm me + + Charge / Effort / Rest + Start session + Synthesis + Key Metrics + Workouts + Heart Rate + Recovery Vitals + Your Cards + Menstrual Cycle + Journal + Added Cards + Today + 24h + 12h + 6h + 3h + 1h + latest + from profile + On-device + Whoop + Apple Health + Health Connect + Mi Band + Nutrition + Cached + Apple Watch + Oura + Fitbit + Garmin + Workout files + " + " + Push + Maintain + Rest + No sources yet + Synced from: %1$s + Restarted when you recalibrated on %1$s — no need to tap it again. + Building, wear it tonight + Building, moves as you do + How %1$s is calculated + How this score is calculated + Strap battery + Strap battery %1$d percent + SOLID + Charge · %1$s + Recovery · %1$s + Show fewer + Show all metrics (%1$d) + No heart rate for this day. Step back to a day the strap was worn. + No heart rate in the last %1$s. Try a wider window or Today. + Calibrating, no heart rate banked yet today. Your curve fills in as the strap offloads. + 5-minute average | last %1$s + Zoomed in · drag to pan + Not connected + Should you push today? + A training read, separate from your Charge score. + Looks like a workout around %1$s–%2$s (avg HR %3$d, %4$d min). Save it? + Looks like a workout yesterday around %1$s–%2$s (avg HR %3$d, %4$d min). Save it? + Looks like a workout on %1$s around %2$s–%3$s (avg HR %4$d, %5$d min). Save it? + HRV + Heart-rate variability + Resting HR + Resting heart rate + Respiratory + Breaths per minute + Blood Oxygen + Blood oxygen + Skin Temp + Skin temperature + Sleep + Last night + Steps + Today + Calories + Active energy + Stress + Autonomic load + Fitness Age + Updated weekly + VO₂ Max + Estimated, updated weekly + Vitality + Wellness score + Hydration + Today\'s fluid + Coupled view + Recovery, strain and sleep in one glance + Charge + Effort + Rest + HRV + Resting HR + Blood oxygen + Respiratory + Steps + Weight + Calories + Readiness + Wear the strap for a few nights and your readiness read will appear here. + HRV + above your baseline - well recovered + in your normal range + a touch below baseline + suppressed - a sign of autonomic fatigue + Resting HR + at or below baseline + running a little high + elevated - overtraining or illness can do this + Respiratory + up vs baseline - sometimes an early sign of getting sick + slightly raised vs baseline + Training variety + low - similar strain every day raises strain/illness risk + Training load + ramping down (acute:chronic %1$s) - room to build + in the sweet spot (acute:chronic %1$s) + building fast (acute:chronic %1$s) - watch fatigue + spiking (acute:chronic %1$s) - higher injury risk + A few more nights of data and your readiness read will sharpen. + Run down + Several signals are down at once. Treat today as recovery - easy movement, real sleep tonight. + Strained + One of your signals is flagging. You can train, but keep it controlled and bank the recovery. + Primed + Your signals are aligned and your load is supported. A harder session is well backed today. + Balanced + Nothing\'s flagging. Train to feel - your body\'s holding steady. + Change day + Collapse + Expand for the full read + See your full readiness + Hide data source details + How Charge is calculated + Reset the heart rate zoom + See what shaped your %1$s + Show what NOOP is synced from + Contributors + Data sources + Beats per minute + No data + Provenance + 7d %1$s / 28d %2$s + %1$s vs %2$s %3$s + monotony %1$s + k=%1$s from %2$d days, %3$s confidence + Calibrating: %1$d/%2$d days + high + low + medium + est. · %1$s + est. + manual k=%1$s + Charge is low. + Charge is low and sleep was consistent. + Charge is low, but sleep ran short. + Charge is steady. + Charge is steady and sleep was consistent. + Charge is steady, but sleep ran short. + Charge is strong. + Charge is strong and sleep was consistent. + Charge is strong, but sleep ran short. + Depleted + Low + No metrics yet. Import your WHOOP export or wear the strap to begin. + Peak + Steady + Strong + bpm + ms + rpm + vs your baseline + What drove Charge + What shaped it + 14 days + Activity + Calibrated by hand + Estimated from %1$d days your phone also counted + Connect your phone’s step count to estimate steps + Need %1$d more days where your phone also counted steps + Learning your baseline, %1$d of %2$d nights. + Last night · %1$s + Min + Avg + Max + Counting… + %1$s days · %2$s workouts + Today · %1$s + Yesterday · %1$s + Today + Yesterday + Building your baseline + Charge, Effort and Rest become personal after a few nights of wear. + Live now. Your scores are building. + Charge, Effort and Rest build over your next few nights of wear. + Your live heart rate is working from the strap, and recovery, strain and sleep build from it over your next few nights of wear, sharpening as it learns your baseline. Want your full history instantly? Import your WHOOP export in Data Sources and it backfills in about a minute. + Session running + Session ended + Start session + Guarding - silence means you\'re on track. + See the summary of your last session. + Strap-guided effort session. It only buzzes when you drift off today\'s band. + BETA + %1$s · last night + Choose which cards show on Today. Cards with no value yet show a dash. + Each line shows how many points that signal moved Charge versus your on-device baseline. Approximate, not medical advice. + SOLID + BUILDING + CALIBRATING + 5-minute average | selected day + 5-minute average | since midnight + 24-hour heart rate + sleep band %1$s + %1$d percent Charge at wake + %1$s Effort now + , + Still + Walking + Running + Good morning + Good afternoon + Good evening + Calibrating + %1$s · %2$s + 7-day trend + 14-day trend + 30-day trend + 1 week + 2 weeks + 1 month + %1$s %2$s. + + ~%1$d hour left + ~%1$d hours left + + + ~%1$d day left + ~%1$d days left + + + %1$d new day of history is ready in Trends. + %1$d new days of history are ready in Trends. + + + %1$d workout marked + %1$d workouts marked + + + %1$s day + %1$s days + + + %1$s workout + %1$s workouts + + Heart rate variability + Resting heart rate + Sleep quality + Respiratory rate + Skin temperature + %1$d ms + %1$d bpm + %1$d%% + %1$s br/min + %1$s C vs baseline + %1$d ms baseline + %1$d bpm baseline + %1$s br/min baseline + above baseline, supporting recovery + below baseline, supporting recovery + above baseline, limiting recovery + below baseline, limiting recovery + at baseline + below baseline, limiting recovery, though low resting HR suggests this may be parasympathetic saturation rather than fatigue + a strong night, supporting recovery + below a good night, limiting recovery + a typical night + near baseline + warmer than baseline, limiting recovery + cooler than baseline, limiting recovery + %1$s, %2$s, %3$s, %4$s points, %5$s + Your daily scores + The three scores + NOOP gives you three daily scores: Charge, Effort and Rest, each from 0 to 100. They are calculated on your device from strap signals and published sport science. They are independent approximations, not WHOOP’s private scores, so the direction may be similar while the numbers differ. + Charge: how recovered are you? + Charge is led by HRV compared with your personal baseline, plus resting heart rate, last night’s Rest, breathing rate and skin temperature. Higher HRV means more Charge. NOOP needs a few nights to learn your baseline; until then it shows “Calibrating”. + The idea is similar to WHOOP’s Recovery %, but our weighting and baseline calculations are independent and openly documented. + Effort: how hard did your heart work? + Effort measures cardiovascular load. Heart rate is converted into a training impulse using heart-rate-reserve zones, with harder zones weighted more heavily, then mapped to a logarithmic 0–100 scale. Steps and active energy provide a floor for long, low-cardio activity. + The idea is similar to WHOOP’s Day Strain (0–21), rescaled to 0–100. A 100 remains as rare as a 21.0. + Rest: how restorative was your sleep? + Rest combines sleep duration versus your need, sleep efficiency, restorative deep and REM sleep, and the consistency of sleep and wake times. + The idea is similar to WHOOP’s Sleep Performance %, but our calculation is independent. + Solid + Building + Each score shows its reliability. Calibrating means NOOP is learning your baseline or needs more data. Building means enough data exists for a score, but it is still limited. Solid means all inputs are present. If NOOP cannot calculate a score reliably, it shows no score instead of guessing. Training Load CTL · Fitness ATL · Fatigue diff --git a/android/app/src/test/java/com/noop/analytics/RD1LogDomainTest.kt b/android/app/src/test/java/com/noop/analytics/RD1LogDomainTest.kt index ddee76d6a8..3f522bcb14 100644 --- a/android/app/src/test/java/com/noop/analytics/RD1LogDomainTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RD1LogDomainTest.kt @@ -39,7 +39,10 @@ class RD1LogDomainTest { // (Baselines spine, reject off): the 8 recent 85 ms nights nudge it up from the 42 ms cluster // but Winsorization caps their pull, so it lands at 49 — still geometric, not the tail-inflated // arithmetic 54. Byte-identical to the Swift RD1LogDomainTests value. - assertEquals("50 vs 49 ms", hrv.evidence) + val evidence = hrv.evidence as ReadinessEngine.Evidence.MetricVsBaseline + assertEquals(50.0, evidence.value, 0.0) + assertEquals(49.0, evidence.baseline, 0.5) + assertEquals(ReadinessEngine.MetricUnit.MS, evidence.unit) // 50 sits at the typical night → neutral, read against a representative (not tail-inflated) baseline. assertEquals(ReadinessEngine.Flag.NEUTRAL, hrv.flag) } diff --git a/android/app/src/test/java/com/noop/analytics/RecoveryDriversTest.kt b/android/app/src/test/java/com/noop/analytics/RecoveryDriversTest.kt index b89f795ddd..8a34dd7bf3 100644 --- a/android/app/src/test/java/com/noop/analytics/RecoveryDriversTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RecoveryDriversTest.kt @@ -42,7 +42,7 @@ class RecoveryDriversTest { hrv = hrv, rhr = rhr, resp = null, hrvBaseline = hrvBaseline, rhrBaseline = rhrBaseline, respBaseline = null, sleepPerf = null, - ).first { it.label == "Heart rate variability" } + ).first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY } return (full - neutral) to row.deltaPoints } @@ -118,11 +118,12 @@ class RecoveryDriversTest { assertEquals( listOf( ChargeDriver( - label = "Heart rate variability", + label = ChargeDriverLabel.HEART_RATE_VARIABILITY, deltaPoints = -1, - valueText = "30 ms", - baselineText = "30 ms baseline", - verdict = "below baseline, limiting recovery", + value = 29.99117725828923, + baseline = 30.0, + unit = ChargeDriverUnit.MILLISECONDS, + verdict = ChargeDriverVerdict.BELOW_BASELINE_LIMITING, ), ), drivers, @@ -139,26 +140,32 @@ class RecoveryDriversTest { ) // All five present terms produce one row each (order is biggest-mover-first, asserted below). assertEquals( - setOf("Heart rate variability", "Resting heart rate", "Respiratory rate", "Sleep quality", "Skin temperature"), + ChargeDriverLabel.entries.toSet(), drivers.map { it.label }.toSet(), ) // Rows are sorted biggest-mover-first, matching the Swift twin. val magnitudes = drivers.map { kotlin.math.abs(it.deltaPoints) } assertEquals(magnitudes.sortedDescending(), magnitudes) - // Every row carries a non-blank value + verdict (never fabricated-empty). HRV / resting HR / - // respiration name a learned baseline; Sleep + Skin temp intentionally carry an empty baseline + // Every row carries a finite numeric value and semantic unit/verdict. HRV / resting HR / + // respiration name a learned baseline; Sleep + Skin temp intentionally carry no baseline // (no learned per-night baseline), exactly as the Swift twin does. drivers.forEach { - assertTrue(it.valueText.isNotBlank()) - assertTrue(it.verdict.isNotBlank()) + assertTrue(it.value.isFinite()) + assertTrue(it.unit in ChargeDriverUnit.entries) + assertTrue(it.verdict in ChargeDriverVerdict.entries) } - listOf("Heart rate variability", "Resting heart rate", "Respiratory rate").forEach { label -> - assertTrue(drivers.first { it.label == label }.baselineText.isNotBlank()) + listOf( + ChargeDriverLabel.HEART_RATE_VARIABILITY, + ChargeDriverLabel.RESTING_HEART_RATE, + ChargeDriverLabel.RESPIRATORY_RATE, + ).forEach { label -> + assertTrue(drivers.first { it.label == label }.baseline != null) } // The HRV row names the night's value + the personal baseline it was scored against. - val hrv = drivers.first { it.label == "Heart rate variability" } - assertEquals("62 ms", hrv.valueText) - assertEquals("50 ms baseline", hrv.baselineText) + val hrv = drivers.first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY } + assertEquals(62.0, hrv.value, 0.0) + assertEquals(50.0, hrv.baseline!!, 0.0) + assertEquals(ChargeDriverUnit.MILLISECONDS, hrv.unit) } @Test fun missingInputYieldsNoRowNotAFakeZero() { @@ -170,11 +177,11 @@ class RecoveryDriversTest { sleepPerf = 0.85, skinTempDev = null, ) val labels = drivers.map { it.label } - assertTrue(labels.contains("Heart rate variability")) - assertTrue(labels.contains("Sleep quality")) - assertFalse(labels.contains("Resting heart rate")) - assertFalse(labels.contains("Respiratory rate")) - assertFalse(labels.contains("Skin temperature")) + assertTrue(labels.contains(ChargeDriverLabel.HEART_RATE_VARIABILITY)) + assertTrue(labels.contains(ChargeDriverLabel.SLEEP_QUALITY)) + assertFalse(labels.contains(ChargeDriverLabel.RESTING_HEART_RATE)) + assertFalse(labels.contains(ChargeDriverLabel.RESPIRATORY_RATE)) + assertFalse(labels.contains(ChargeDriverLabel.SKIN_TEMPERATURE)) } @Test fun deltaSignTracksDirection() { @@ -185,12 +192,12 @@ class RecoveryDriversTest { rhrBaseline = baseline(55.0, 3.0), respBaseline = null, sleepPerf = null, skinTempDev = null, ) - val hrv = drivers.first { it.label == "Heart rate variability" } - val rhr = drivers.first { it.label == "Resting heart rate" } + val hrv = drivers.first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY } + val rhr = drivers.first { it.label == ChargeDriverLabel.RESTING_HEART_RATE } assertTrue("HRV above baseline should lift Charge", hrv.deltaPoints > 0) assertTrue("Elevated resting HR should pull Charge down", rhr.deltaPoints < 0) - assertTrue(hrv.verdict.contains("supporting recovery")) - assertTrue(rhr.verdict.contains("limiting recovery")) + assertEquals(ChargeDriverVerdict.ABOVE_BASELINE_SUPPORTING, hrv.verdict) + assertEquals(ChargeDriverVerdict.ABOVE_BASELINE_LIMITING, rhr.verdict) } @Test fun skinTempIsARelativeDeviationNeverAbsolute() { @@ -200,26 +207,18 @@ class RecoveryDriversTest { rhrBaseline = baseline(55.0, 3.0), respBaseline = null, sleepPerf = null, skinTempDev = 0.4, ) - val skin = drivers.first { it.label == "Skin temperature" } - assertTrue("skin temp must read as a +/- deviation", skin.valueText.contains("vs baseline")) - assertTrue(skin.valueText.contains("+0.4")) + val skin = drivers.first { it.label == ChargeDriverLabel.SKIN_TEMPERATURE } + assertEquals(ChargeDriverUnit.CELSIUS_DEVIATION, skin.unit) + assertEquals(0.4, skin.value, 0.0) + assertNull(skin.baseline) // The symmetric penalty never lifts Charge. assertTrue(skin.deltaPoints <= 0) } - @Test fun skinTempAndRespirationFormattingUseSharedUSContract() { - val expectedSkinText = listOf( - -0.35 to "-0.4 C vs baseline", - 0.35 to "+0.4 C vs baseline", - -0.34 to "-0.3 C vs baseline", - 0.34 to "+0.3 C vs baseline", - -0.36 to "-0.4 C vs baseline", - 0.36 to "+0.4 C vs baseline", - -0.0 to "-0.0 C vs baseline", - 0.0 to "+0.0 C vs baseline", - ) + @Test fun skinTempAndRespirationPreserveRawSemanticMeasurements() { + val deviations = listOf(-0.35, 0.35, -0.34, 0.34, -0.36, 0.36, -0.0, 0.0) - expectedSkinText.forEach { (deviation, expected) -> + deviations.forEach { deviation -> val drivers = RecoveryDrivers.chargeDrivers( hrv = 46.0, rhr = 58.0, resp = 14.0, hrvBaseline = baseline(51.0, 6.265), @@ -227,14 +226,16 @@ class RecoveryDriversTest { respBaseline = baseline(15.0, 1.8795, nValid = 12), sleepPerf = 0.9, skinTempDev = deviation, ) - val skin = drivers.first { it.label == "Skin temperature" } - assertEquals(expected, skin.valueText) - assertEquals("", skin.baselineText) + val skin = drivers.first { it.label == ChargeDriverLabel.SKIN_TEMPERATURE } + assertEquals(deviation.toBits(), skin.value.toBits()) + assertNull(skin.baseline) + assertEquals(ChargeDriverUnit.CELSIUS_DEVIATION, skin.unit) assertTrue(skin.deltaPoints <= 0) - val respiration = drivers.first { it.label == "Respiratory rate" } - assertEquals("14.0 br/min", respiration.valueText) - assertEquals("15.0 br/min baseline", respiration.baselineText) + val respiration = drivers.first { it.label == ChargeDriverLabel.RESPIRATORY_RATE } + assertEquals(14.0, respiration.value, 0.0) + assertEquals(15.0, respiration.baseline!!, 0.0) + assertEquals(ChargeDriverUnit.BREATHS_PER_MINUTE, respiration.unit) } } @@ -260,7 +261,7 @@ class RecoveryDriversTest { sleepPerf = 0.9, skinTempDev = -0.5, ) drivers.forEach { d -> - val all = "${d.label}${d.valueText}${d.baselineText}${d.verdict}" + val all = "${d.label}${d.unit}${d.verdict}" assertFalse("driver row must not contain an em-dash", all.contains("\u2014")) } } diff --git a/android/app/src/test/java/com/noop/analytics/RecoverySaturationGuardTest.kt b/android/app/src/test/java/com/noop/analytics/RecoverySaturationGuardTest.kt index 8b8d8e93f5..972afdee83 100644 --- a/android/app/src/test/java/com/noop/analytics/RecoverySaturationGuardTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RecoverySaturationGuardTest.kt @@ -230,14 +230,14 @@ class RecoverySaturationGuardTest { hrv = 41.0, rhr = 48.0, resp = null, hrvBaseline = hrvB, rhrBaseline = rhrB, respBaseline = null, sleepPerf = null, ) - val satHRV = sat.first { it.label == "Heart rate variability" } - assertTrue("verdict must surface the detection", satHRV.verdict.contains("saturation")) - assertTrue( - "verdict must not imply the penalty was lifted: ${satHRV.verdict}", - satHRV.verdict.contains("limiting recovery"), + val satHRV = sat.first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY } + assertEquals( + "verdict must surface the detection while preserving the limiting meaning", + ChargeDriverVerdict.HRV_SATURATION_LIMITING, + satHRV.verdict, ) - assertFalse(satHRV.verdict.contains("penalty is eased")) - assertFalse(satHRV.verdict.contains("—")) + assertFalse(satHRV.verdict == ChargeDriverVerdict.BELOW_BASELINE_LIMITING) + assertFalse(satHRV.verdict == ChargeDriverVerdict.ABOVE_BASELINE_SUPPORTING) assertTrue("the HRV penalty is still fully charged", satHRV.deltaPoints < 0) // Real-fatigue night: plain limiting verdict, no saturation mention. @@ -245,9 +245,9 @@ class RecoverySaturationGuardTest { hrv = 41.0, rhr = 62.0, resp = null, hrvBaseline = hrvB, rhrBaseline = rhrB, respBaseline = null, sleepPerf = null, ) - val fatHRV = fat.first { it.label == "Heart rate variability" } - assertTrue(fatHRV.verdict.contains("limiting recovery")) - assertFalse(fatHRV.verdict.contains("saturation")) + val fatHRV = fat.first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY } + assertEquals(ChargeDriverVerdict.BELOW_BASELINE_LIMITING, fatHRV.verdict) + assertFalse(fatHRV.verdict == ChargeDriverVerdict.HRV_SATURATION_LIMITING) assertTrue(fatHRV.deltaPoints < 0) } } diff --git a/android/app/src/test/java/com/noop/analytics/StepsEstimateEngineTest.kt b/android/app/src/test/java/com/noop/analytics/StepsEstimateEngineTest.kt index 985fcf9d7d..555f639b47 100644 --- a/android/app/src/test/java/com/noop/analytics/StepsEstimateEngineTest.kt +++ b/android/app/src/test/java/com/noop/analytics/StepsEstimateEngineTest.kt @@ -129,7 +129,7 @@ class StepsEstimateEngineTest { assertEquals(2, status.have) assertEquals(3, status.need) assertTrue(!status.canEstimate) - assertEquals("Need 1 more day where your phone also counted steps", status.headline) + assertEquals(StepsEstimateEngine.CalibrationStatus.Headline.NeedMoreDays(1), status.headline) } @Test fun statusHeadlineNoPhoneSourceIsActionableNotAFrozenCountdown() { @@ -141,7 +141,7 @@ class StepsEstimateEngineTest { val status = StepsEstimateEngine.status(pts) status as StepsEstimateEngine.CalibrationStatus.NeedsMoreDays assertEquals(0, status.have) - assertEquals("Connect your phone's step count to estimate steps", status.headline) + assertEquals(StepsEstimateEngine.CalibrationStatus.Headline.ConnectPhoneSteps, status.headline) } } @@ -154,7 +154,7 @@ class StepsEstimateEngineTest { assertEquals(3, status.sampleDays) assertTrue(status.confidence > 0) assertTrue(status.canEstimate) - assertEquals("Estimated from 3 days your phone also counted", status.headline) + assertEquals(StepsEstimateEngine.CalibrationStatus.Headline.Calibrated(3), status.headline) } @Test fun statusManualOverrideWinsEvenWithNoDays() { @@ -164,7 +164,7 @@ class StepsEstimateEngineTest { assertEquals(42.0, status.coefficient, 1e-9) assertEquals(0, status.sampleDays) assertTrue(status.canEstimate) - assertEquals("Calibrated by hand", status.headline) + assertEquals(StepsEstimateEngine.CalibrationStatus.Headline.Manual, status.headline) } // calibration STATUS surfaced on the tile (#760/#792 - k / days / confidence self-explain). Mirrors Swift. @@ -184,24 +184,30 @@ class StepsEstimateEngineTest { ) assertEquals(StepsEstimateEngine.ConfidenceTier.LOW, status.confidenceTier) assertEquals(12.34, status.coefficientOrNull, 1e-9) - assertEquals("k=12.3 from 6 days, low confidence", status.detail) + assertEquals( + StepsEstimateEngine.CalibrationStatus.Detail.Calibrated(12.34, 6, StepsEstimateEngine.ConfidenceTier.LOW), + status.detail, + ) val one = StepsEstimateEngine.CalibrationStatus.Calibrated( coefficient = 5.0, sampleDays = 1, confidence = 0.8, ) assertEquals(StepsEstimateEngine.ConfidenceTier.HIGH, one.confidenceTier) - assertEquals("k=5.0 from 1 day, high confidence", one.detail) + assertEquals( + StepsEstimateEngine.CalibrationStatus.Detail.Calibrated(5.0, 1, StepsEstimateEngine.ConfidenceTier.HIGH), + one.detail, + ) } @Test fun statusDetailManualAndNeedsMoreDays() { val manual = StepsEstimateEngine.CalibrationStatus.Manual(coefficient = 9.5, sampleDays = 0) assertEquals(StepsEstimateEngine.ConfidenceTier.HIGH, manual.confidenceTier) assertEquals(9.5, manual.coefficientOrNull, 1e-9) - assertEquals("manual k=9.5", manual.detail) + assertEquals(StepsEstimateEngine.CalibrationStatus.Detail.Manual(9.5), manual.detail) val needs = StepsEstimateEngine.CalibrationStatus.NeedsMoreDays(have = 1, need = 3) assertEquals(StepsEstimateEngine.ConfidenceTier.LOW, needs.confidenceTier) assertNull(needs.coefficientOrNull) - assertEquals("calibrating: 1/3 days", needs.detail) + assertEquals(StepsEstimateEngine.CalibrationStatus.Detail.Calibrating(1, 3), needs.detail) val over = StepsEstimateEngine.CalibrationStatus.NeedsMoreDays(have = 9, need = 3) - assertEquals("calibrating: 3/3 days", over.detail) + assertEquals(StepsEstimateEngine.CalibrationStatus.Detail.Calibrating(3, 3), over.detail) } } diff --git a/android/app/src/test/java/com/noop/ui/HrWindowTest.kt b/android/app/src/test/java/com/noop/ui/HrWindowTest.kt index 98b3ec7173..29c4db1e29 100644 --- a/android/app/src/test/java/com/noop/ui/HrWindowTest.kt +++ b/android/app/src/test/java/com/noop/ui/HrWindowTest.kt @@ -66,7 +66,14 @@ class HrWindowTest { fun pill_order_is_today_then_widest_to_narrowest() { // Declaration order IS the pill order, and TODAY must stay ordinal 0: the rememberSaveable // default is the ordinal, so reordering the enum would silently change the default window. - assertEquals(listOf("Today", "24h", "12h", "6h", "3h", "1h"), HrWindow.entries.map { it.label }) + assertEquals( + listOf( + com.noop.R.string.today_hr_window_today, com.noop.R.string.today_hr_window_24h, + com.noop.R.string.today_hr_window_12h, com.noop.R.string.today_hr_window_6h, + com.noop.R.string.today_hr_window_3h, com.noop.R.string.today_hr_window_1h, + ), + HrWindow.entries.map { it.labelRes }, + ) assertEquals(0, HrWindow.TODAY.ordinal) } } diff --git a/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt index ba7406f71b..e93cd6065b 100644 --- a/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt +++ b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt @@ -1,6 +1,7 @@ package com.noop.ui import com.noop.analytics.ScoreConfidence +import com.noop.analytics.ChargeDriverLabel import com.noop.data.DailyMetric import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -39,10 +40,10 @@ class RecoveryDriversUiTest { val drivers = recoveryChargeDrivers(days, days.last()) assertTrue("a usable baseline should yield driver rows", drivers.isNotEmpty()) val labels = drivers.map { it.label } - assertTrue(labels.contains("Heart rate variability")) - assertTrue(labels.contains("Resting heart rate")) + assertTrue(labels.contains(ChargeDriverLabel.HEART_RATE_VARIABILITY)) + assertTrue(labels.contains(ChargeDriverLabel.RESTING_HEART_RATE)) // Skin-temp was supplied on the scored day, so its row is present. - assertTrue(labels.contains("Skin temperature")) + assertTrue(labels.contains(ChargeDriverLabel.SKIN_TEMPERATURE)) } @Test fun coldStartHistoryProducesNoRows() { diff --git a/android/app/src/test/java/com/noop/ui/TodayChargeTapCollapseTest.kt b/android/app/src/test/java/com/noop/ui/TodayChargeTapCollapseTest.kt index 3bd474cb3d..0e56f3f837 100644 --- a/android/app/src/test/java/com/noop/ui/TodayChargeTapCollapseTest.kt +++ b/android/app/src/test/java/com/noop/ui/TodayChargeTapCollapseTest.kt @@ -1,5 +1,6 @@ package com.noop.ui +import com.noop.R import com.noop.analytics.ReadinessEngine import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -15,10 +16,10 @@ class TodayChargeTapCollapseTest { @Test fun readinessWord_mapsEveryLevel() { - assertEquals("Push", readinessWord(ReadinessEngine.Level.PRIMED)) - assertEquals("Maintain", readinessWord(ReadinessEngine.Level.BALANCED)) - assertEquals("Rest", readinessWord(ReadinessEngine.Level.STRAINED)) - assertEquals("Rest", readinessWord(ReadinessEngine.Level.RUNDOWN)) + assertEquals(R.string.today_readiness_push, readinessWord(ReadinessEngine.Level.PRIMED)) + assertEquals(R.string.today_readiness_maintain, readinessWord(ReadinessEngine.Level.BALANCED)) + assertEquals(R.string.today_readiness_rest, readinessWord(ReadinessEngine.Level.STRAINED)) + assertEquals(R.string.today_readiness_rest, readinessWord(ReadinessEngine.Level.RUNDOWN)) } @Test @@ -29,15 +30,15 @@ class TodayChargeTapCollapseTest { @Test fun syncedFromSummary_listsOnlySourcesWithData() { assertEquals( - "Synced from: WHOOP, Apple Watch", + listOf(DisplayText.Resource(R.string.today_source_whoop), DisplayText.Resource(R.string.today_source_apple_watch)), syncedFromSummary(hasWhoop = true, hasApple = true, hasXiaomi = false), ) assertEquals( - "Synced from: WHOOP", + listOf(DisplayText.Resource(R.string.today_source_whoop)), syncedFromSummary(hasWhoop = true, hasApple = false, hasXiaomi = false), ) assertEquals( - "Synced from: WHOOP, Apple Watch, Mi Band", + listOf(DisplayText.Resource(R.string.today_source_whoop), DisplayText.Resource(R.string.today_source_apple_watch), DisplayText.Resource(R.string.today_source_mi_band)), syncedFromSummary(hasWhoop = true, hasApple = true, hasXiaomi = true), ) } @@ -45,7 +46,7 @@ class TodayChargeTapCollapseTest { @Test fun syncedFromSummary_appleHealthReadsAsAppleWatch() { assertEquals( - "Synced from: Apple Watch", + listOf(DisplayText.Resource(R.string.today_source_apple_watch)), syncedFromSummary(hasWhoop = false, hasApple = true, hasXiaomi = false), ) } @@ -54,15 +55,15 @@ class TodayChargeTapCollapseTest { fun syncedFromSummary_healthConnectReadsAsHealthConnect() { // #176: a Health-Connect-only user must NOT see "Synced from: Apple Watch". assertEquals( - "Synced from: Health Connect", + listOf(DisplayText.Resource(R.string.today_source_health_connect)), syncedFromSummary(hasWhoop = false, hasApple = false, hasHealthConnect = true, hasXiaomi = false), ) assertEquals( - "Synced from: WHOOP, Health Connect", + listOf(DisplayText.Resource(R.string.today_source_whoop), DisplayText.Resource(R.string.today_source_health_connect)), syncedFromSummary(hasWhoop = true, hasApple = false, hasHealthConnect = true, hasXiaomi = false), ) assertEquals( - "Synced from: WHOOP, Apple Watch, Health Connect", + listOf(DisplayText.Resource(R.string.today_source_whoop), DisplayText.Resource(R.string.today_source_apple_watch), DisplayText.Resource(R.string.today_source_health_connect)), syncedFromSummary(hasWhoop = true, hasApple = true, hasHealthConnect = true, hasXiaomi = false), ) } @@ -70,7 +71,7 @@ class TodayChargeTapCollapseTest { @Test fun syncedFromSummary_noSourcesIsHonest() { assertEquals( - "No sources yet", + emptyList(), syncedFromSummary(hasWhoop = false, hasApple = false, hasXiaomi = false), ) } diff --git a/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt b/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt index 1775926cc0..c9c9bd8fff 100644 --- a/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt +++ b/android/app/src/test/java/com/noop/ui/TodayExplainabilityTest.kt @@ -1,5 +1,6 @@ package com.noop.ui +import com.noop.R import com.noop.analytics.FusionSource import com.noop.data.DailyMetric import org.junit.Assert.assertEquals @@ -21,6 +22,8 @@ import org.junit.Test */ class TodayExplainabilityTest { + private fun res(id: Int) = DisplayText.Resource(id) + private fun day(key: String, recovery: Double? = null, deviceId: String = "my-whoop") = DailyMetric(deviceId = deviceId, day = key, recovery = recovery) @@ -38,11 +41,8 @@ class TodayExplainabilityTest { // 1 night banked, seed 4 → "about 3 more nights". No fabricated value. val state = scoreStateForToday(todayRecovery = null, calibratingNights = 1, carriedDay = null, seed = 4) assertEquals(ScoreState.Calibrating(3), state) - assertEquals("Calibrating", state.title) - assertEquals( - "Building your baseline. About 3 more nights until your scores are personal.", - state.detail, - ) + assertEquals(R.string.score_state_title_calibrating, state.titleRes) + assertEquals(R.plurals.score_state_detail_calibrating, state.detailRes) } @Test @@ -50,10 +50,7 @@ class TodayExplainabilityTest { // 3 of 4 banked → exactly one more night → singular "night". val state = scoreStateForToday(todayRecovery = null, calibratingNights = 3, carriedDay = null, seed = 4) assertEquals(ScoreState.Calibrating(1), state) - assertEquals( - "Building your baseline. About 1 more night until your scores are personal.", - state.detail, - ) + assertEquals(R.plurals.score_state_detail_calibrating, state.detailRes) } @Test @@ -71,8 +68,8 @@ class TodayExplainabilityTest { val state = scoreStateForToday(todayRecovery = null, calibratingNights = null, carriedDay = prior, today = "2026-01-15") assertEquals(ScoreState.CarriedLastNight("14 Jan", false), state) - assertEquals("Last night · 14 Jan", state.title) - assertEquals("Tonight's lands after you sleep with the strap on.", state.detail) + assertEquals(R.string.score_state_title_last_night, state.titleRes) + assertEquals(R.string.score_state_detail_carried_fresh, state.detailRes) } @Test @@ -83,17 +80,17 @@ class TodayExplainabilityTest { val state = scoreStateForToday(todayRecovery = null, calibratingNights = null, carriedDay = prior, today = "2026-02-11") assertEquals(ScoreState.CarriedLastNight("14 Jan", true), state) - assertEquals("Latest sleep · 14 Jan", state.title) - assertEquals("This is your last scored session. Wear the strap overnight for a fresh score.", state.detail) + assertEquals(R.string.score_state_title_latest_sleep, state.titleRes) + assertEquals(R.string.score_state_detail_carried_stale, state.detailRes) } @Test fun carriedCaption_capsLastNightToTwoDays() { // Within the cap → "Last night"; older → "Latest sleep". The cap is inclusive at 2 days. (#779) assertEquals(false, isCarryStale("2026-01-13", "2026-01-15")) - assertEquals("Last night · 13 Jan", carriedCaption("2026-01-13", "2026-01-15")) + assertEquals(DisplayText.Resource(R.string.score_state_title_last_night, listOf("13 Jan")), carriedCaption("2026-01-13", "2026-01-15")) assertEquals(true, isCarryStale("2026-01-12", "2026-01-15")) - assertEquals("Latest sleep · 12 Jan", carriedCaption("2026-01-12", "2026-01-15")) + assertEquals(DisplayText.Resource(R.string.score_state_title_latest_sleep, listOf("12 Jan")), carriedCaption("2026-01-12", "2026-01-15")) // An unparseable key never reads stale (never over-claims). assertEquals(false, isCarryStale("not-a-date", "2026-01-15")) } @@ -102,8 +99,8 @@ class TodayExplainabilityTest { fun scoreState_needsStrap_whenNothingToShow() { val state = scoreStateForToday(todayRecovery = null, calibratingNights = null, carriedDay = null) assertEquals(ScoreState.NeedsStrap, state) - assertEquals("Needs the strap", state.title) - assertEquals("No data for today. Was your strap worn and connected overnight?", state.detail) + assertEquals(R.string.score_state_title_needs_strap, state.titleRes) + assertEquals(R.string.score_state_detail_needs_strap, state.detailRes) } @Test @@ -119,9 +116,8 @@ class TodayExplainabilityTest { // Honesty: the no-own-value states have an empty value slot — the copy carries no figure. val calibrating = scoreStateForToday(todayRecovery = null, calibratingNights = 0, carriedDay = null, seed = 4) val needsStrap = scoreStateForToday(todayRecovery = null, calibratingNights = null, carriedDay = null) - // No "%" or other number sneaks into the title. - assertTrue(!calibrating.title.contains("%")) - assertTrue(!needsStrap.title.contains("%")) + assertEquals(R.string.score_state_title_calibrating, calibrating.titleRes) + assertEquals(R.string.score_state_title_needs_strap, needsStrap.titleRes) } // ── COMPONENT 3 — recording state ──────────────────────────────────────────────────────────────── @@ -130,8 +126,8 @@ class TodayExplainabilityTest { fun recording_whenConnectedAndLiveHr() { val state = recordingStateFor(connected = true, liveHeartRate = 58, lastSyncAtSec = null, nowSec = 1_000_000) assertEquals(RecordingState.Recording, state) - assertEquals("Recording", state.title) - assertEquals("Your strap is connected and saving data.", state.detail) + assertEquals(R.string.recording_chip_title_recording, state.titleRes) + assertEquals(R.string.recording_chip_detail_recording, state.detailRes) assertEquals(StrandTone.Positive, state.tone) } @@ -174,8 +170,8 @@ class TodayExplainabilityTest { val now = 1_000_000L val state = recordingStateFor(connected = false, liveHeartRate = null, lastSyncAtSec = now - 540, nowSec = now) assertEquals(RecordingState.LastSynced(9), state) - assertEquals("Last synced 9m ago", state.title) - assertEquals("Reconnect to pull the latest.", state.detail) + assertEquals(R.string.recording_chip_title_last_synced, state.titleRes) + assertEquals(R.string.recording_chip_detail_last_synced, state.detailRes) assertEquals(StrandTone.Neutral, state.tone) } @@ -187,7 +183,7 @@ class TodayExplainabilityTest { val now = 1_000_000L val state = recordingStateFor(connected = false, liveHeartRate = null, lastSyncAtSec = now - 30, nowSec = now) assertEquals(RecordingState.LastSynced(1), state) - assertEquals("Last synced 1m ago", state.title) + assertEquals(R.string.recording_chip_title_last_synced, state.titleRes) } @Test @@ -196,7 +192,7 @@ class TodayExplainabilityTest { val now = 1_000_000L val state = recordingStateFor(connected = false, liveHeartRate = null, lastSyncAtSec = now - 481, nowSec = now) assertEquals(RecordingState.LastSynced(9), state) - assertEquals("Last synced 9m ago", state.title) + assertEquals(R.string.recording_chip_title_last_synced, state.titleRes) // 1 second ago still rounds up to a whole minute. val oneSecond = recordingStateFor(connected = false, liveHeartRate = null, lastSyncAtSec = now - 1, nowSec = now) assertEquals(RecordingState.LastSynced(1), oneSecond) @@ -208,7 +204,7 @@ class TodayExplainabilityTest { val now = 1_000_000L val state = recordingStateFor(connected = false, liveHeartRate = null, lastSyncAtSec = now, nowSec = now) assertEquals(RecordingState.LastSynced(0), state) - assertEquals("Last synced 0m ago", state.title) + assertEquals(R.string.recording_chip_title_last_synced, state.titleRes) } @Test @@ -218,15 +214,15 @@ class TodayExplainabilityTest { val now = 1_000_000L val state = recordingStateFor(connected = false, liveHeartRate = null, lastSyncAtSec = now + 30, nowSec = now) assertEquals(RecordingState.LastSynced(0), state) - assertEquals("Last synced 0m ago", state.title) + assertEquals(R.string.recording_chip_title_last_synced, state.titleRes) } @Test fun notRecording_whenNoConnectionAndNoSync() { val state = recordingStateFor(connected = false, liveHeartRate = null, lastSyncAtSec = null, nowSec = 1_000_000) assertEquals(RecordingState.NotRecording, state) - assertEquals("Not recording", state.title) - assertEquals("Strap not connected. Tap to connect.", state.detail) + assertEquals(R.string.recording_chip_title_not_recording, state.titleRes) + assertEquals(R.string.recording_chip_detail_not_recording, state.detailRes) assertEquals(StrandTone.Critical, state.tone) } @@ -235,19 +231,19 @@ class TodayExplainabilityTest { @Test fun dayOwner_noopComputedSibling_mapsToOnDevice() { assertEquals(FusionSource.NOOP_COMPUTED, dayOwnerSource("my-whoop-noop")) - assertEquals("On-device", provenanceBadgeLabel(dayOwnerSource("my-whoop-noop"))) + assertEquals(res(R.string.today_source_on_device), provenanceBadgeLabel(dayOwnerSource("my-whoop-noop"))) } @Test fun dayOwner_importedStrap_mapsToWhoop() { assertEquals(FusionSource.WHOOP_IMPORT, dayOwnerSource("my-whoop")) - assertEquals("Whoop", provenanceBadgeLabel(dayOwnerSource("my-whoop"))) + assertEquals(res(R.string.today_source_whoop), provenanceBadgeLabel(dayOwnerSource("my-whoop"))) } @Test fun dayOwner_appleAndHealthConnect_keptSeparate() { - assertEquals("Apple Health", provenanceBadgeLabel(dayOwnerSource("apple-health"))) - assertEquals("Health Connect", provenanceBadgeLabel(dayOwnerSource("health-connect"))) + assertEquals(res(R.string.today_source_apple_health), provenanceBadgeLabel(dayOwnerSource("apple-health"))) + assertEquals(res(R.string.today_source_health_connect), provenanceBadgeLabel(dayOwnerSource("health-connect"))) } @Test @@ -259,8 +255,8 @@ class TodayExplainabilityTest { @Test fun provenanceLabel_isNeverBlanketOnDevice_forImports() { // Honesty: an imported source must NOT be relabelled "On-device". - assertEquals("Whoop", provenanceBadgeLabel(FusionSource.WHOOP_IMPORT)) - assertEquals("Apple Health", provenanceBadgeLabel(FusionSource.APPLE_HEALTH)) + assertEquals(res(R.string.today_source_whoop), provenanceBadgeLabel(FusionSource.WHOOP_IMPORT)) + assertEquals(res(R.string.today_source_apple_health), provenanceBadgeLabel(FusionSource.APPLE_HEALTH)) } // ── COMPONENT 4 — PER-METRIC provenance (provenanceDisplayLabel) ───────────────────────────────────── @@ -275,13 +271,13 @@ class TodayExplainabilityTest { @Test fun perMetric_computedSibling_readsOnDevice() { // The "$deviceId-noop" sibling is a score NOOP computed on THIS device from the raw strap stream. - assertEquals("On-device", provenanceDisplayLabel("my-whoop-noop")) + assertEquals(res(R.string.today_source_on_device), provenanceDisplayLabel("my-whoop-noop")) } @Test fun perMetric_importedStrap_readsWhoop() { // The imported strap source (the deviceId itself, normally "my-whoop") is a real WHOOP export. - assertEquals("Whoop", provenanceDisplayLabel("my-whoop")) + assertEquals(res(R.string.today_source_whoop), provenanceDisplayLabel("my-whoop")) } @Test @@ -290,35 +286,35 @@ class TodayExplainabilityTest { // day must read its REAL source, never a blanket "On-device". So when the resolver returns the // import source for, say, "recovery" while the day's other fields are computed, the Charge badge // reads "Whoop" — and an Apple-Health-won metric reads "Apple Health" — not the day's deviceId. - assertEquals("Whoop", provenanceDisplayLabel("my-whoop")) - assertEquals("Apple Health", provenanceDisplayLabel("apple-health")) + assertEquals(res(R.string.today_source_whoop), provenanceDisplayLabel("my-whoop")) + assertEquals(res(R.string.today_source_apple_health), provenanceDisplayLabel("apple-health")) } @Test fun perMetric_appleHealth_readsAppleHealth() { - assertEquals("Apple Health", provenanceDisplayLabel("apple-health")) + assertEquals(res(R.string.today_source_apple_health), provenanceDisplayLabel("apple-health")) } @Test fun perMetric_otherKnownSource_keepsFusionDisplayName() { // Any other real source keeps its FusionSource.displayName (the genuine winner), never blanketed. - assertEquals("Health Connect", provenanceDisplayLabel("health-connect")) - assertEquals("Mi Band", provenanceDisplayLabel("xiaomi-band")) + assertEquals(res(R.string.today_source_health_connect), provenanceDisplayLabel("health-connect")) + assertEquals(res(R.string.today_source_mi_band), provenanceDisplayLabel("xiaomi-band")) } @Test fun perMetric_unknownSource_fallsBackToRawId() { // An unrecognised raw id falls through to itself verbatim rather than guessing a label. - assertEquals("garmin-import", provenanceDisplayLabel("garmin-import")) + assertEquals(DisplayText.Dynamic("garmin-import"), provenanceDisplayLabel("garmin-import")) } @Test fun perMetric_honoursACustomStrapDeviceId() { // The deviceId is parameterised (mirrors Swift's repo.deviceId): a custom strap id and its "-noop" // sibling still resolve to "Whoop" / "On-device", and the FIXED "my-whoop" import still reads "Whoop". - assertEquals("On-device", provenanceDisplayLabel("strap-42-noop", deviceId = "strap-42")) - assertEquals("Whoop", provenanceDisplayLabel("strap-42", deviceId = "strap-42")) - assertEquals("Whoop", provenanceDisplayLabel("my-whoop", deviceId = "strap-42")) + assertEquals(res(R.string.today_source_on_device), provenanceDisplayLabel("strap-42-noop", deviceId = "strap-42")) + assertEquals(res(R.string.today_source_whoop), provenanceDisplayLabel("strap-42", deviceId = "strap-42")) + assertEquals(res(R.string.today_source_whoop), provenanceDisplayLabel("my-whoop", deviceId = "strap-42")) } @Test @@ -326,34 +322,34 @@ class TodayExplainabilityTest { // A "-noop" sibling banked under a DIFFERENT strap id (the user re-paired straps) is still a // score NOOP computed on-device. The resolver matches the "-noop" suffix, not the exact // "$deviceId-noop" — otherwise these rows would fall through to the raw id verbatim. - assertEquals("On-device", provenanceDisplayLabel("whoop5-C0FF-noop", deviceId = "my-whoop")) - assertEquals("On-device", provenanceDisplayLabel("my-whoop-noop", deviceId = "strap-42")) + assertEquals(res(R.string.today_source_on_device), provenanceDisplayLabel("whoop5-C0FF-noop", deviceId = "my-whoop")) + assertEquals(res(R.string.today_source_on_device), provenanceDisplayLabel("my-whoop-noop", deviceId = "strap-42")) } @Test fun todayScoreProviderLabel_coversImportedAndRegisteredProviders() { - assertEquals("Whoop", todayScoreProviderLabel(ScoreInputProvider("my-whoop", "WHOOP"))) - assertEquals("Apple Watch", todayScoreProviderLabel(ScoreInputProvider("apple-health"))) - assertEquals("Health Connect", todayScoreProviderLabel(ScoreInputProvider("health-connect"))) - assertEquals("Oura", todayScoreProviderLabel(ScoreInputProvider("oura-import"))) - assertEquals("Fitbit", todayScoreProviderLabel(ScoreInputProvider("fitbit-import"))) - assertEquals("Garmin", todayScoreProviderLabel(ScoreInputProvider("garmin-import"))) - assertEquals("Mi Band", todayScoreProviderLabel(ScoreInputProvider("xiaomi-band"))) + assertEquals(res(R.string.today_source_whoop), todayScoreProviderLabel(ScoreInputProvider("my-whoop", "WHOOP"))) + assertEquals(res(R.string.today_source_apple_watch), todayScoreProviderLabel(ScoreInputProvider("apple-health"))) + assertEquals(res(R.string.today_source_health_connect), todayScoreProviderLabel(ScoreInputProvider("health-connect"))) + assertEquals(res(R.string.today_source_oura), todayScoreProviderLabel(ScoreInputProvider("oura-import"))) + assertEquals(res(R.string.today_source_fitbit), todayScoreProviderLabel(ScoreInputProvider("fitbit-import"))) + assertEquals(res(R.string.today_source_garmin), todayScoreProviderLabel(ScoreInputProvider("garmin-import"))) + assertEquals(res(R.string.today_source_mi_band), todayScoreProviderLabel(ScoreInputProvider("xiaomi-band"))) for (brand in com.noop.data.DeviceBrandCatalog.all.map { it.brand }) { - assertEquals(brand, todayScoreProviderLabel(ScoreInputProvider("device-$brand", brand))) + assertEquals(DisplayText.Dynamic(brand), todayScoreProviderLabel(ScoreInputProvider("device-$brand", brand))) } } @Test fun todayScoreProviderLabel_unknownSourceDoesNotPretendToBeWhoop() { - assertEquals("sensor-42", todayScoreProviderLabel(ScoreInputProvider("sensor-42"))) - assertEquals("not-a-whoop", todayScoreProviderLabel(ScoreInputProvider("not-a-whoop"))) + assertEquals(DisplayText.Dynamic("sensor-42"), todayScoreProviderLabel(ScoreInputProvider("sensor-42"))) + assertEquals(DisplayText.Dynamic("not-a-whoop"), todayScoreProviderLabel(ScoreInputProvider("not-a-whoop"))) } @Test fun liquidHeroSourceLabel_deduplicatesOneProvider() { assertEquals( - "Polar", + listOf(DisplayText.Dynamic("Polar")), heroSourceLabel( listOf( ScoreInputProvider("polar-1", "Polar"), @@ -367,7 +363,7 @@ class TodayExplainabilityTest { @Test fun liquidHeroSourceLabel_capsMixedProvidersAtTwoInScoreOrder() { assertEquals( - "Whoop + Oura", + listOf(res(R.string.today_source_whoop), res(R.string.today_source_oura)), heroSourceLabel( listOf( ScoreInputProvider("my-whoop", "WHOOP"), @@ -380,18 +376,18 @@ class TodayExplainabilityTest { @Test fun liquidHeroSourceLabel_usesAudienceFacingAppleWatchName() { - assertEquals("Apple Watch", heroSourceLabel(listOf(ScoreInputProvider("apple-health")))) + assertEquals(listOf(res(R.string.today_source_apple_watch)), heroSourceLabel(listOf(ScoreInputProvider("apple-health")))) } @Test fun liquidHeroSourceLabel_hidesWhenNoScoreHasAResolvedSource() { - assertNull(heroSourceLabel(emptyList())) + assertEquals(emptyList(), heroSourceLabel(emptyList())) } @Test fun liquidHeroSourceLabel_usesCarriedChargeSourceWhenTodayRecoveryIsAbsent() { assertEquals( - "Whoop", + listOf(res(R.string.today_source_whoop)), scoreHeroSourceLabel( providerByMetric = emptyMap(), carriedRecoveryProvider = ScoreInputProvider("my-whoop", "WHOOP"), @@ -403,7 +399,7 @@ class TodayExplainabilityTest { @Test fun liquidHeroSourceLabel_keepsCurrentDayRecoveryAheadOfCarriedFallback() { assertEquals( - "Oura", + listOf(res(R.string.today_source_oura)), scoreHeroSourceLabel( providerByMetric = mapOf("recovery" to ScoreInputProvider("oura-import")), carriedRecoveryProvider = ScoreInputProvider("my-whoop", "WHOOP"), @@ -414,7 +410,8 @@ class TodayExplainabilityTest { @Test fun liquidHeroSourceLabel_ignoresCarriedSourceWhenChargeIsNotCarried() { - assertNull( + assertEquals( + emptyList(), scoreHeroSourceLabel( providerByMetric = emptyMap(), carriedRecoveryProvider = ScoreInputProvider("my-whoop", "WHOOP"), diff --git a/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt b/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt index 6aae386e1a..4444dabc01 100644 --- a/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt +++ b/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt @@ -66,7 +66,7 @@ class TodayMetricTilesTest { fun weightTile_usesLatestReading_metric() { val t = weightTile(latestWeightKg = 74.5, profileWeightKg = 90.0, system = UnitSystem.METRIC) assertEquals("74.5 kg", t.value) - assertEquals("latest", t.caption) + assertEquals(WeightCaption.LATEST, t.caption) } @Test @@ -74,14 +74,14 @@ class TodayMetricTilesTest { val t = weightTile(latestWeightKg = 100.0, profileWeightKg = 90.0, system = UnitSystem.IMPERIAL) // 100 kg * 2.20462 = 220.462 lb assertEquals("220.5 lb", t.value) - assertEquals("latest", t.caption) + assertEquals(WeightCaption.LATEST, t.caption) } @Test fun weightTile_fallsBackToProfile_withHonestCaption() { val t = weightTile(latestWeightKg = null, profileWeightKg = 75.0, system = UnitSystem.METRIC) assertEquals("75.0 kg", t.value) - assertEquals("from profile", t.caption) + assertEquals(WeightCaption.FROM_PROFILE, t.caption) } @Test @@ -89,7 +89,7 @@ class TodayMetricTilesTest { val t = weightTile(latestWeightKg = null, profileWeightKg = 75.0, system = UnitSystem.IMPERIAL) // 75 kg * 2.20462 = 165.3465 lb assertEquals("165.3 lb", t.value) - assertEquals("from profile", t.caption) + assertEquals(WeightCaption.FROM_PROFILE, t.caption) } // MARK: stepsForDay — Today Steps-tile fallback to imported Apple Health / Health Connect (#150) @@ -129,12 +129,12 @@ class TodayMetricTilesTest { @Test fun buildingHint_rest_today_isTheWearItTonightCopy() { - assertEquals("Building, wear it tonight", buildingHint(KeyMetric.REST, isToday = true)) + assertEquals(com.noop.R.string.today_building_wear_tonight, buildingHint(KeyMetric.REST, isToday = true)) } @Test fun buildingHint_effort_today_isTheMovesAsYouDoCopy() { - assertEquals("Building, moves as you do", buildingHint(KeyMetric.EFFORT, isToday = true)) + assertEquals(com.noop.R.string.today_building_moves_with_you, buildingHint(KeyMetric.EFFORT, isToday = true)) } @Test @@ -149,19 +149,19 @@ class TodayMetricTilesTest { @Test fun buildingHint_charge_today_isTheWearItTonightCopy() { // H10: a cold-start Charge (no score, not calibrating, nothing carried) reads "building", not blank. - assertEquals("Building, wear it tonight", buildingHint(KeyMetric.CHARGE, isToday = true)) + assertEquals(com.noop.R.string.today_building_wear_tonight, buildingHint(KeyMetric.CHARGE, isToday = true)) } @Test fun buildingHint_bloodOxygen_today_buildsLikeTheOtherOvernightVitals() { // H10: the overnight SpO₂ fills in from sleep, like Rest. - assertEquals("Building, wear it tonight", buildingHint(KeyMetric.BLOOD_OXYGEN, isToday = true)) + assertEquals(com.noop.R.string.today_building_wear_tonight, buildingHint(KeyMetric.BLOOD_OXYGEN, isToday = true)) } @Test fun buildingHint_steps_today_movesAsYouDo() { // H10: on-device steps accrue across the day, like Effort. - assertEquals("Building, moves as you do", buildingHint(KeyMetric.STEPS, isToday = true)) + assertEquals(com.noop.R.string.today_building_moves_with_you, buildingHint(KeyMetric.STEPS, isToday = true)) } @Test @@ -181,12 +181,12 @@ class TodayMetricTilesTest { } @Test - fun buildingHint_copy_hasNoEmDash() { - // House style: user-facing strings carry no em-dashes (the #1 AI tell). - for (m in listOf(KeyMetric.REST, KeyMetric.EFFORT, KeyMetric.CHARGE, KeyMetric.BLOOD_OXYGEN, KeyMetric.STEPS)) { - val hint = buildingHint(m, isToday = true)!! - assert(!hint.contains('—')) { "buildingHint($m) must not contain an em-dash: $hint" } - } + fun buildingHint_returnsLocalizedResourceContracts() { + assertEquals(com.noop.R.string.today_building_wear_tonight, buildingHint(KeyMetric.REST, true)) + assertEquals(com.noop.R.string.today_building_moves_with_you, buildingHint(KeyMetric.EFFORT, true)) + assertEquals(com.noop.R.string.today_building_wear_tonight, buildingHint(KeyMetric.CHARGE, true)) + assertEquals(com.noop.R.string.today_building_wear_tonight, buildingHint(KeyMetric.BLOOD_OXYGEN, true)) + assertEquals(com.noop.R.string.today_building_moves_with_you, buildingHint(KeyMetric.STEPS, true)) } // MARK: restStageLowConfidence — H9. Surfaces the core ScoreConfidence rule: a high-efficiency night diff --git a/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt b/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt index dcf81b48f6..b8a0483b7a 100644 --- a/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt +++ b/android/app/src/test/java/com/noop/ui/VitalReadingsTableTest.kt @@ -1,5 +1,6 @@ package com.noop.ui +import com.noop.R import com.noop.data.WhoopRepository import java.time.LocalDate import java.util.Locale @@ -45,7 +46,14 @@ class VitalReadingsTableTest { @Test fun sourceLabelsResolvePerSample() { val rows = vitalReadingRows(spo2Readings, "%", strap, spo2Format) // Newest-first, so: Apple Health (03), Health Connect (02), Whoop strap (01). - assertEquals(listOf("Apple Health", "Health Connect", "Whoop"), rows.map { it.source }) + assertEquals( + listOf( + DisplayText.Resource(R.string.today_source_apple_health), + DisplayText.Resource(R.string.today_source_health_connect), + DisplayText.Resource(R.string.today_source_whoop), + ), + rows.map { it.source }, + ) } @Test fun computedStrapSiblingReadsOnDevice() { @@ -55,7 +63,7 @@ class VitalReadingsTableTest { strap, { it.roundToInt().toString() }, ) - assertEquals("On-device", rows.single().source) + assertEquals(DisplayText.Resource(R.string.today_source_on_device), rows.single().source) } @Test fun valueReusesModelFormatAndUnit() { From 88bf7a4d98e671cd62bd4974cc35033f1e85180e Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 10:50:57 +0200 Subject: [PATCH 02/12] Localize Android score labels and guide --- Tools/test_home_i18n.py | 18 +++++ .../java/com/noop/ui/ScoringGuideScreen.kt | 67 ++++++------------- .../src/main/java/com/noop/ui/TodayScreen.kt | 14 +++- .../app/src/main/res/values-de/strings.xml | 27 ++++++++ .../app/src/main/res/values-es/strings.xml | 22 ++++++ .../app/src/main/res/values-fr/strings.xml | 33 +++++++++ .../app/src/main/res/values-pl/strings.xml | 28 ++++++++ .../src/main/res/values-pt-rPT/strings.xml | 26 +++++++ .../app/src/main/res/values-zh/strings.xml | 18 +++++ android/app/src/main/res/values/strings.xml | 14 ++++ 10 files changed, 219 insertions(+), 48 deletions(-) diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index 1a776520a9..8bf0658a80 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -231,6 +231,24 @@ def test_android_score_section_label_is_resolved_at_ui_boundary(self) -> None: source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) self.assertNotRegex(source, r"val\s+label\s*:\s*String", "ScoreSection must expose a resource contract") + def test_android_home_score_labels_and_explain_copy_are_resource_backed(self) -> None: + today_relative = "android/app/src/main/java/com/noop/ui/TodayScreen.kt" + today = audit._mask_comments((ROOT / today_relative).read_text(encoding="utf-8")) + self.assertNotIn("domain.label", today, "Home score names must be resolved from Android resources") + + guide_relative = "android/app/src/main/java/com/noop/ui/ScoringGuideScreen.kt" + findings = [ + (guide_relative, line, literal) + for line, literal in _all_kotlin_literals(ROOT / guide_relative) + if _is_helper_copy(literal) + and literal not in { + "${(sampleFraction * 100).roundToInt()}", + "noop_scoring_guide_prefs", + "scoringGuideCardSeen", + } + ] + self.assertEqual([], findings, "Raw Android score-explainer copy:\n" + _format_findings(findings)) + def test_android_today_source_counts_use_two_plural_resources(self) -> None: relative = "android/app/src/main/java/com/noop/ui/TodayScreen.kt" source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) diff --git a/android/app/src/main/java/com/noop/ui/ScoringGuideScreen.kt b/android/app/src/main/java/com/noop/ui/ScoringGuideScreen.kt index 9e4aa61bcb..e2f174d007 100644 --- a/android/app/src/main/java/com/noop/ui/ScoringGuideScreen.kt +++ b/android/app/src/main/java/com/noop/ui/ScoringGuideScreen.kt @@ -190,39 +190,25 @@ fun ScoringGuideScreen( IntroCard() ScoreCard( section = ScoreSection.CHARGE, - headline = "Charge: how recovered are you?", - body = "Led by your heart-rate variability (HRV) measured against your own " + - "personal baseline, plus resting heart rate, last night's Rest, breathing " + - "rate, and a skin-temperature signal (an early illness or overreach flag). " + - "Higher HRV versus your baseline means more Charge. NOOP needs a few nights " + - "to learn your baseline first. Until then you'll see “Calibrating”.", - vsWhoop = "Same core idea as WHOOP's Recovery % (HRV-led recovery), but our " + - "weighting and baseline maths are our own, and openly documented.", + headlineRes = R.string.scoring_guide_charge_headline, + bodyRes = R.string.scoring_guide_charge_body, + vsWhoopRes = R.string.scoring_guide_charge_vs_whoop, highlighted = highlighted == ScoreSection.CHARGE, onPositioned = { if (ScoreSection.CHARGE !in anchors) anchors[ScoreSection.CHARGE] = it }, ) ScoreCard( section = ScoreSection.EFFORT, - headline = "Effort: how hard did your heart work?", - body = "Your cardiovascular load. NOOP turns every second of heart rate into a " + - "training-impulse using heart-rate-reserve zones (Karvonen), weights time in " + - "harder zones more heavily (Edwards / Banister), and places it on a " + - "logarithmic 0-100 scale, so easy days sit low and an all-out day approaches " + - "100, which stays genuinely rare. A long walk with little cardio still counts, " + - "through a steps / active-energy floor.", - vsWhoop = "Same cardiovascular-load idea as WHOOP's Day Strain (0-21). We " + - "rescaled the top of the ladder from 21 to 100 so all three scores share one " + - "scale. The rungs didn't move, so a 100 is as rare as a 21.0 was.", + headlineRes = R.string.scoring_guide_effort_headline, + bodyRes = R.string.scoring_guide_effort_body, + vsWhoopRes = R.string.scoring_guide_effort_vs_whoop, highlighted = highlighted == ScoreSection.EFFORT, onPositioned = { if (ScoreSection.EFFORT !in anchors) anchors[ScoreSection.EFFORT] = it }, ) ScoreCard( section = ScoreSection.REST, - headline = "Rest: how restorative was your sleep?", - body = "A blend of how long you slept versus your personal need (the biggest " + - "factor), how efficiently (asleep versus in bed), how much was restorative " + - "(deep + REM sleep), and how consistent your sleep and wake timing is.", - vsWhoop = "Similar in spirit to WHOOP's Sleep Performance %; our composite is our own.", + headlineRes = R.string.scoring_guide_rest_headline, + bodyRes = R.string.scoring_guide_rest_body, + vsWhoopRes = R.string.scoring_guide_rest_vs_whoop, highlighted = highlighted == ScoreSection.REST, onPositioned = { if (ScoreSection.REST !in anchors) anchors[ScoreSection.REST] = it }, ) @@ -251,7 +237,7 @@ private fun Header(onClose: () -> Unit) { modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Overline("Your daily scores", color = Palette.textTertiary) + Overline(uiString(R.string.scoring_guide_daily_scores), color = Palette.textTertiary) Text(uiString(R.string.l10n_scoring_guide_screen_how_your_scores_work_21a0e2be), style = NoopType.display(26f), color = Palette.textPrimary) Text( uiString(R.string.l10n_scoring_guide_screen_charge_effort_rest_f396a530), @@ -276,15 +262,9 @@ private fun Header(onClose: () -> Unit) { private fun IntroCard() { NoopCard(padding = 20.dp) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - Overline("The three scores") + Overline(uiString(R.string.scoring_guide_three_scores)) Text( - uiString(R.string.l10n_scoring_guide_screen_noop_gives_you_three_daily_scores_36244209) + - "scale. They're built from your strap's raw signals using published, " + - "peer-reviewed sport science, and computed entirely on your device. They are " + - "NOT WHOOP's scores: we don't have WHOOP's private algorithms and don't pretend " + - "to. They aim at the same three questions using open science, so they'll usually " + - "track WHOOP's in direction, but won't match number-for-number. And that's the " + - "point.", + uiString(R.string.scoring_guide_intro_body), style = NoopType.subhead, color = Palette.textSecondary, ) @@ -318,9 +298,9 @@ private fun LegendDot(section: ScoreSection) { @Composable private fun ScoreCard( section: ScoreSection, - headline: String, - body: String, - vsWhoop: String, + @StringRes headlineRes: Int, + @StringRes bodyRes: Int, + @StringRes vsWhoopRes: Int, highlighted: Boolean, onPositioned: (Int) -> Unit, ) { @@ -368,10 +348,10 @@ private fun ScoreCard( color = section.accent, ) } - Text(headline, style = NoopType.headline, color = Palette.textPrimary) + Text(stringResource(headlineRes), style = NoopType.headline, color = Palette.textPrimary) } } - Text(body, style = NoopType.subhead, color = Palette.textSecondary) + Text(stringResource(bodyRes), style = NoopType.subhead, color = Palette.textSecondary) Hairline() Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -384,7 +364,7 @@ private fun ScoreCard( modifier = Modifier.padding(top = 1.dp), ) Text( - vsWhoop, + stringResource(vsWhoopRes), style = NoopType.footnote.copy(fontStyle = FontStyle.Italic), color = Palette.textTertiary, ) @@ -432,15 +412,12 @@ private fun ConfidenceCard() { color = Palette.textPrimary, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - StatePill("Solid", tone = StrandTone.Positive, showsDot = true) - StatePill("Building", tone = StrandTone.Warning, showsDot = true) - StatePill("Calibrating", tone = StrandTone.Neutral, showsDot = true) + StatePill(uiString(R.string.scoring_guide_solid), tone = StrandTone.Positive, showsDot = true) + StatePill(uiString(R.string.scoring_guide_building), tone = StrandTone.Warning, showsDot = true) + StatePill(uiString(R.string.today_calibrating), tone = StrandTone.Neutral, showsDot = true) } Text( - uiString(R.string.l10n_scoring_guide_screen_every_score_carries_a_small_honesty_e5b722eb) + - "learning your baseline, or doesn't have enough data yet. Building means there's " + - "enough to show, but it's thin. Solid means full inputs are present. When NOOP " + - "can't compute a score honestly, it shows nothing rather than a fake number.", + uiString(R.string.scoring_guide_confidence_body), style = NoopType.subhead, color = Palette.textSecondary, ) diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 953afe6f4f..3a6b065c40 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -2660,6 +2660,14 @@ private fun HeroRingColumn( onRingTap: (() -> Unit)? = null, ring: @Composable () -> Unit, ) { + val domainLabel = uiString( + when (domain) { + DomainTheme.Charge -> R.string.today_metric_charge + DomainTheme.Effort -> R.string.today_metric_effort + DomainTheme.Rest -> R.string.today_metric_rest + DomainTheme.Stress -> R.string.today_card_stress + }, + ) Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -2676,7 +2684,7 @@ private fun HeroRingColumn( .clickable( interactionSource = ringInteraction, indication = null, - onClickLabel = uiString(R.string.today_action_see_what_shaped, domain.label), + onClickLabel = uiString(R.string.today_action_see_what_shaped, domainLabel), onClick = onRingTap, ), ) { ring() } @@ -2703,12 +2711,12 @@ private fun HeroRingColumn( ) { // #74: never wrap the hero label onto a second line — at a larger font/screen-zoom (Samsung // One UI defaults) "REST" could wrap, growing the whole hero card. One line, ellipsis if forced. - Text(domain.label.uppercase(), style = NoopType.overline, color = Palette.textSecondary, + Text(domainLabel.uppercase(), style = NoopType.overline, color = Palette.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 16.dp)) Icon( Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = uiString(R.string.l10n_today_screen_how_domain_label_is_calculated_8897768c, domain.label), + contentDescription = uiString(R.string.l10n_today_screen_how_domain_label_is_calculated_8897768c, domainLabel), tint = Palette.textSecondary.copy(alpha = 0.6f), modifier = Modifier.align(Alignment.CenterEnd).size(14.dp), ) diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index d72f87c0af..76c3603371 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2428,6 +2428,33 @@ Stabil Im Aufbau Jeder Wert zeigt seine Verlässlichkeit. „Kalibrierung“ bedeutet, dass NOOP deine Basislinie noch lernt oder mehr Daten benötigt. „Im Aufbau“ bedeutet, dass genügend, aber noch wenige Daten vorliegen. „Stabil“ bedeutet, dass alle Eingaben vorhanden sind. Ist keine verlässliche Berechnung möglich, zeigt NOOP lieber keinen Wert als eine Schätzung. + %1$s – %2$s + Gelöschte Schlafzeiträume + Gelöschtes Schlaffenster ausgeblendet. Es bleibt gelöscht. + Guten Morgen! + Dieses gelöschte Schlaffenster konnte nicht ausgeblendet werden. Versuche es erneut. + Ausblenden + Dieses gelöschte Schlaffenster ausblenden + Diese Nacht konnte nicht wieder geöffnet werden. Versuche es erneut. + Wenn dieser Schlaf nur aus einem Import stammt und keine Rohdaten gespeichert sind, wird er möglicherweise nicht wieder angezeigt. + Vielleicht später + Bewegt euch sowieso + Bewegen Sie diesen Schlaf? + Nickerchen + Nächste Nacht + Noch keine frühere Nacht gespeichert. Frühere Nächte synchronisieren am Morgen. + Keine Nickerchen für diesen Tag aufgezeichnet. + Keine Etappenlücke für diese Nacht. + Keine Phasendaten für diese Nacht aufgezeichnet. + Offenes Journal + Vorherige Nacht + Berechne eine Nacht neu, um ihre Löschmarkierung zu entfernen und die verfügbaren Rohdaten erneut zu prüfen. + Diese gelöschte Schlafaufzeichnung neu berechnen + Diese Nacht neu berechnen + Wird neu berechnet… + Schlaf + Die Schlaferkennung wurde mit den für diese Nacht verfügbaren Daten erneut ausgeführt. + Phasen-Aufschlüsselung Trainingslast CTL · Kondition ATL · Ermüdung diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 1f27dae7c6..36baf882e0 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2414,6 +2414,28 @@ Sólido En desarrollo Cada puntuación indica su fiabilidad. «Calibrando» significa que NOOP aprende tu línea base o necesita más datos. «En desarrollo» indica que hay datos suficientes, pero aún limitados. «Sólido» indica que están presentes todos los datos. Si el cálculo no es fiable, NOOP no muestra una puntuación en vez de adivinar. + Experimental: a su propio riesgo + NOOP habla con el firmware de tu correa sobre un protocolo no oficial, de forma independiente. Existe un riesgo residual para el dispositivo, sus datos y su conexión con los servicios oficiales. Asumes ese riesgo. + No un dispositivo médico, no un consejo médico + Cada métrica es una aproximación no validada. No utilice NOOP para diagnosticar, tratar o tomar cualquier decisión de salud. Siempre consulte a un profesional calificado. + Ninguna garantía; limitación de responsabilidad + NOOP es gratuito y proporciona \"as es\", sin garantía. La responsabilidad se limita a la máxima medida que la ley que se aplica a usted permite, y nada aquí elimina las protecciones que su derecho local no nos deja eliminar. + Los términos completos están en TERMS.md, incluido con NOOP. Esto no es asesoría legal. + Por favor, confirme cada uno de estos: + No soy un empleado, contratista o afiliado de WHOOP, y no estoy usando NOOP en nombre de WHOOP. + Soy dueño del dispositivo WHOOP que usaré con NOOP y sólo lo usaré para acceder a mis propios datos. Hacerlo es mi decisión y mi riesgo, incluyendo cualquier efecto en mi cuenta WHOOP, suscripción, dispositivo o garantía, y puede violar los Términos de servicio de WHOOP\. + Entiendo que NOOP es no oficial y experimental, se proporciona gratuitamente y \"as es\" sin garantía, y no es un dispositivo médico o consejo médico. + En la medida en que la ley lo permita, no sostendré el proyecto NOOP ni sus colaboradores responsables de ninguna pérdida o daño derivado de mi uso. + He leído y acepto estos términos, y uso NOOP con mi propio dispositivo y mis propios datos, bajo mi propio riesgo. + Aceptar y continuar + El hilo de ti a lo largo del tiempo. + Últimos + Últimos %1$d días + Todo el historial + Tendencia + Tendencia: %1$s + Carga + Esfuerzo Carga de entrenamiento CTL · Forma física ATL · Fatiga diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bfc9bf5496..3a76751946 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2413,6 +2413,39 @@ Fiable En cours Chaque score indique sa fiabilité. « Calibration en cours » signifie que NOOP apprend votre référence ou manque de données. « En cours » signifie que les données suffisent mais restent limitées. « Fiable » signifie que toutes les entrées sont présentes. Si le calcul n’est pas fiable, NOOP n’affiche rien plutôt que d’inventer. + Pas un dispositif médical, pas un avis médical + Chaque métrique est une approximation non validée. N\'utilisez pas NOOP pour diagnostiquer, traiter ou prendre une décision de santé. Consultez toujours un professionnel qualifié. + Aucune garantie ; responsabilité limitée + NOOP est gratuit et fourni \"as is\", sans garantie. La responsabilité est limitée dans la mesure maximale que la loi qui s\'applique à vous permet, et rien ici ne supprime les protections de votre loi locale ne nous permet pas de supprimer. + Les conditions complètes se trouvent dans TERMS.md, fourni avec NOOP. Ceci n\'est pas un conseil juridique. + Veuillez confirmer chacun de ces éléments : + Je ne suis pas un employé, entrepreneur ou affilié de WHOOP, et je n\'utilise pas NOOP au nom de WHOOP\. + Je possède l\'appareil WHOOP que je vais utiliser avec NOOP et je ne l\'utiliserai que pour accéder à mes propres données. Ce faisant est ma décision et mon risque, y compris tout effet sur mon compte WHOOP, abonnement, appareil, ou garantie, et il peut enfreindre les Conditions de service de WHOOP\. + Je comprends que NOOP est non officiel et expérimental, est fourni gratuitement et \"as is\" sans garantie, et n\'est pas un dispositif médical ou un conseil médical. + Dans toute la mesure permise par la loi, je ne tiendrai pas le projet NOOP ou ses contributeurs responsables de toute perte ou dommage découlant de mon utilisation de celui-ci. + J\'ai lu et j\'accepte ces conditions, et j\'utilise NOOP avec mon propre appareil et mes propres données, à mes propres risques. + Accepter et continuer + Le fil de vous au fil du temps. + Derniers + %1$d derniers jours + Tout l’historique + Tendance + Tendance : %1$s + Charge + Effort + Repos + Variabilité de la fréquence cardiaque + Fréquence cardiaque au repos + Aucune lecture cette semaine + Passez à une autre semaine avec les flèches ci-dessus pour voir son bilan. + Cette semaine + La semaine dernière + Semaine précédente + Semaine prochaine + Signaux quotidiens + Résumé de la semaine + Charge · Effort · Repos + Calendrier Charge d’entraînement CTL · Forme ATL · Fatigue diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 338566cefc..2317ddb6ee 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2419,6 +2419,34 @@ Pewny W trakcie Każdy wynik pokazuje wiarygodność. „Kalibracja” oznacza naukę wartości bazowej lub brak danych. „W trakcie” oznacza, że danych wystarcza, ale nadal jest ich niewiele. „Pewny” oznacza komplet danych. Gdy obliczenie nie jest wiarygodne, NOOP nie pokazuje wyniku zamiast zgadywać. + Przesunąć ten sen? + Drzemki + Następna noc + Nie zapisano jeszcze żadnej wcześniejszej nocy. Wcześniejsze noce synchronizują się rano. + W tym dniu nie zapisano żadnych drzemek. + Tego wieczoru nie będzie podziału na etapy. + Brak danych scenicznych zarejestrowanych dla tej nocy. + Otwórz dziennik + Poprzednia noc + Oblicz ponownie noc, aby usunąć znacznik usunięcia i ponownie przeskanuj dostępne surowe dane. + Oblicz ponownie tę usuniętą noc snu + Przelicz jeszcze raz tę noc + Przeliczam ponownie… + Sen + Wykrywanie snu zostało ponownie przeprowadzone przy użyciu danych dostępnych dla tej nocy. + Podział sceny + Spowoduje to przeniesienie nocy do czasu, w którym nie ma zarejestrowanych danych. Nie można tam wyprowadzić etapów, więc może być wyświetlany jako pusty, dopóki nie pokryją go dane. + Dane z Twojej nocy są już dostępne. Rejestrowanie Twojego samopoczucia pomaga NOOP dowiedzieć się, co zapewnia najlepszą regenerację. + Równowaga autonomiczna (LF/HF) + średnio + Indeks stresu Baevsky\'ego + Codziennie 0-3 proxy + Moc wysokiej częstotliwości + Nie ma jeszcze wystarczającej liczby ostatnich dni, aby wyznaczyć trend. Noś pasek, aby go zapełnić. + z 3 + na żądanie · dzisiejszy R-R + w skali obciążenia autonomicznego 0-3 + Linia śledzi obciążenie autonomiczne w ciągu dnia, punktowane Obciążenie treningowe CTL · Kondycja ATL · Zmęczenie diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 4c4258a462..eb83a1ff92 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2407,6 +2407,32 @@ Sólido Em formação Cada pontuação indica a fiabilidade. «A calibrar» significa que o NOOP aprende a tua linha de base ou precisa de mais dados. «Em formação» indica dados suficientes, mas ainda limitados. «Sólido» indica todos os dados presentes. Se o cálculo não for fiável, o NOOP não mostra uma pontuação em vez de adivinhar. + Nenhum cochilo registado neste dia. + Sem quebra de palco para esta noite. + Sem dados de palco registados para esta noite. + Diário aberto + Noite anterior + Recalcule uma noite para limpar o teu marcador de eliminação e verifique novamente os dados brutos disponíveis. + Recalcule esta noite de sono excluída + Recalcular esta noite + Recalculando… + Sono + A deteção do sono foi novamente executada utilizando os dados disponíveis para esta noite. + Análise do estágio + Isto move a noite para um horário sem dados registados. Os estágios não podem ser derivados aí, pelo que pode aparecer como vazio até que os dados o cubram. + Os teus dados noturnos estão disponíveis. Registar como se sentiu ajuda o NOOP a aprender o que impulsiona a tua melhor recuperação. + Equilíbrio autónomo (LF/HF) + avg + Índice de Stress Baevsky + Procuração diária 0-3 + Potência de alta frequência + Ainda não há dias suficientes para traçar uma tendência. Continue a usar a tua bracelete para preenchê-la. + de 3 + on demand · R-R de hoje + na escala de carga autónoma 0-3 + A linha acompanha a tua carga autónoma ao longo do dia, pontuada + Estas são lentes HRV extra a pedido, calculadas a partir dos intervalos RR atuais. São informativos e não alteram a pontuação de stress acima. + Comparámos a frequência cardíaca em repouso e a VFC de hoje com a tua frequência cardíaca de 30 dias Carga de treino CTL · Forma física ATL · Fadiga diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 88252f8de9..c1e2e43013 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2336,6 +2336,24 @@ 可靠 建立中 每项评分都会显示可靠性。“校准中”表示 NOOP 正在学习基线或需要更多数据;“建立中”表示数据足以评分但仍有限;“可靠”表示输入齐全。如果无法可靠计算,NOOP 会留空而不是猜测。 + 卡片背后的天空 + 睡眠的分类、评分、记录机制,以及您的数据从何而来。 + 睡眠分期 V2 算法 + 睡眠分期 (V2 算法) + 部分手机还需要手动开启“自启动”权限 — 请前往系统设置中开启 + 步数校准 + 步数估算校准功能。%1$s。点击打开校准界面。 + 步数估算 + 手环设备 + 手环名称 + 压力签到提醒 (震动) + 支持与联系 + 温度 + 测试中心 + 主题 + 这是一个未加密的普通压缩包——任何拿到文件的人都能用任意压缩工具打开它。请把它存放在你信任的地方。 + 这将重新启动大约需要 4 个晚上的充能(Charge)与 HRV 基准建立过程。您的历史数据都会被保留下来。如果因为您第一周佩戴时状态不佳(比如生病),导致基线严重偏离了正常水平,请使用此功能。 + 趋势图表 移除此次睡眠并重新计算当天数据。之后几秒内可以撤销。 移除此次已记录的睡眠并重新计算当天数据。NOOP 不会在此时间段内重新检测睡眠。之后几秒内可以撤销。 可选 · VO₂max 由约 4 晚的心率数据构建;腰围可让它更准确。健康年龄本身并不需要它。请在肚脐高度绕腰一周测量。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b9550d6763..a6f0e71128 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2440,6 +2440,20 @@ Solid Building Each score shows its reliability. Calibrating means NOOP is learning your baseline or needs more data. Building means enough data exists for a score, but it is still limited. Solid means all inputs are present. If NOOP cannot calculate a score reliably, it shows no score instead of guessing. + This restarts the roughly 4-night build-up for Charge and your HRV baseline. Your history stays. Use it if a bad first week, like wearing it while sick, set your baseline off. + Trend charts + Try WHOOP 5/MG protocol probes + Units + Unlock WHOOP 5/MG deep data (R22) + Unlock WHOOP 5/MG deep data + Version %1$s is available + Waist (optional) + Weight + What each strap can read, and why some features differ. + What\'s new + What\'s new in NOOP %1$s + Whole night is NOOP\'s default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores your recent nights over the new window and takes effect right away once you have a few nights of data. + WHOOP 4.0 versus 5.0: what each can read and why Training Load CTL · Fitness ATL · Fatigue From 1e6383b10ed3eb2a6aaf01f25fee7263199e13a7 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 11:15:19 +0200 Subject: [PATCH 03/12] Fix localized score bubble alignment --- Tools/test_home_i18n.py | 7 ++++ .../src/main/java/com/noop/ui/Components.kt | 2 + .../src/main/java/com/noop/ui/TodayScreen.kt | 41 ++++++++++++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index 8bf0658a80..5189bec628 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -249,6 +249,13 @@ def test_android_home_score_labels_and_explain_copy_are_resource_backed(self) -> ] self.assertEqual([], findings, "Raw Android score-explainer copy:\n" + _format_findings(findings)) + def test_android_home_score_labels_and_empty_states_fit_localized_copy(self) -> None: + source = (ROOT / "android/app/src/main/java/com/noop/ui/TodayScreen.kt").read_text(encoding="utf-8") + self.assertIn("text = domainLabel.uppercase()", source) + self.assertIn("private fun RingNoData(diameter: Dp)", source) + self.assertNotIn("Text(domainLabel.uppercase()", source) + self.assertNotIn("private fun RingNoData()", source) + def test_android_today_source_counts_use_two_plural_resources(self) -> None: relative = "android/app/src/main/java/com/noop/ui/TodayScreen.kt" source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) diff --git a/android/app/src/main/java/com/noop/ui/Components.kt b/android/app/src/main/java/com/noop/ui/Components.kt index d18f61bc51..f6adc556bc 100644 --- a/android/app/src/main/java/com/noop/ui/Components.kt +++ b/android/app/src/main/java/com/noop/ui/Components.kt @@ -484,6 +484,7 @@ internal fun AutoSizeValue( color: Color, modifier: Modifier = Modifier, minScale: Float = 0.6f, + textAlign: TextAlign = TextAlign.Start, ) { var scale by remember(text, style) { mutableStateOf(1f) } Text( @@ -494,6 +495,7 @@ internal fun AutoSizeValue( maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, + textAlign = textAlign, modifier = modifier, onTextLayout = { result -> if (result.didOverflowWidth && scale > minScale) { diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 3a6b065c40..fd5a859455 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -2589,7 +2589,7 @@ private fun ScoreHeroRow( showsValue = strain != null, format = { if (effortScale == EffortScale.WHOOP) String.format(Locale.getDefault(), "%.1f", it) else it.toInt().toString() }, ) - if (strain == null) RingNoData() + if (strain == null) RingNoData(diameter = ring) } } // REST, sleep composite 0–100. Its fixed-width box also anchors the card-level source badge: @@ -2616,7 +2616,7 @@ private fun ScoreHeroRow( // NOT fabricate a Rest number , an aggregate genuinely has no scored night. A day with // no Charge either (truly empty) keeps the plain "No Data". Mirrors iOS restRing. if (restScore == null) { - if (recovery != null) RingNeedsTrackedNight() else RingNoData() + if (recovery != null) RingNeedsTrackedNight() else RingNoData(diameter = ring) } } } @@ -2709,16 +2709,26 @@ private fun HeroRingColumn( .padding(vertical = 2.dp), contentAlignment = Alignment.Center, ) { - // #74: never wrap the hero label onto a second line — at a larger font/screen-zoom (Samsung - // One UI defaults) "REST" could wrap, growing the whole hero card. One line, ellipsis if forced. - Text(domainLabel.uppercase(), style = NoopType.overline, color = Palette.textSecondary, - maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 16.dp)) + // Keep the word centred on the vessel independently of the trailing chevron. Giving the label + // the complete fixed score-column width prevents longer translations (for example ERHOLUNG) + // from pushing the third column right or being clipped at the screen edge. + AutoSizeValue( + text = domainLabel.uppercase(), + style = NoopType.overline, + color = Palette.textSecondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + minScale = 0.7f, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) Icon( Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = uiString(R.string.l10n_today_screen_how_domain_label_is_calculated_8897768c, domainLabel), tint = Palette.textSecondary.copy(alpha = 0.6f), - modifier = Modifier.align(Alignment.CenterEnd).size(14.dp), + modifier = Modifier + .align(Alignment.CenterEnd) + .size(14.dp), ) } } @@ -2973,13 +2983,22 @@ private fun RingEmptyOverlay( ) } } else { - RingNoData() + RingNoData(diameter = diameter) } } @Composable -private fun RingNoData() { - Text(uiString(R.string.today_no_data), style = NoopType.headline, color = Palette.textTertiary, maxLines = 1) +private fun RingNoData(diameter: Dp) { + Box(modifier = Modifier.size(diameter), contentAlignment = Alignment.Center) { + AutoSizeValue( + text = uiString(R.string.today_no_data), + style = NoopType.headline, + color = Palette.textTertiary, + modifier = Modifier.width(diameter * 0.78f), + minScale = 0.65f, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } } /** #898: the Rest ring's overlay when a Charge exists for the day but there's no scored sleep (the From 01f0dfda468ca73845f7d0c4359f5b59adc7041f Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 11:22:51 +0200 Subject: [PATCH 04/12] Wrap empty score text in hero bubbles --- Tools/test_home_i18n.py | 2 ++ android/app/src/main/java/com/noop/ui/TodayScreen.kt | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index 5189bec628..a7da8b900a 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -253,6 +253,8 @@ def test_android_home_score_labels_and_empty_states_fit_localized_copy(self) -> source = (ROOT / "android/app/src/main/java/com/noop/ui/TodayScreen.kt").read_text(encoding="utf-8") self.assertIn("text = domainLabel.uppercase()", source) self.assertIn("private fun RingNoData(diameter: Dp)", source) + self.assertIn("maxLines = 2", source) + self.assertIn("overflow = TextOverflow.Clip", source) self.assertNotIn("Text(domainLabel.uppercase()", source) self.assertNotIn("private fun RingNoData()", source) diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index fd5a859455..ce5e94d6e8 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -2990,13 +2990,15 @@ private fun RingEmptyOverlay( @Composable private fun RingNoData(diameter: Dp) { Box(modifier = Modifier.size(diameter), contentAlignment = Alignment.Center) { - AutoSizeValue( + Text( text = uiString(R.string.today_no_data), style = NoopType.headline, color = Palette.textTertiary, modifier = Modifier.width(diameter * 0.78f), - minScale = 0.65f, textAlign = androidx.compose.ui.text.style.TextAlign.Center, + maxLines = 2, + softWrap = true, + overflow = TextOverflow.Clip, ) } } From cbbf8aab7c427c60e3ca7547ae5b5b2851e0a3c6 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 11:53:15 +0200 Subject: [PATCH 05/12] Reconcile Home catalogs with current upstream --- .../app/src/main/res/values-de/strings.xml | 27 --------------- .../app/src/main/res/values-es/strings.xml | 22 ------------- .../app/src/main/res/values-fr/strings.xml | 33 ------------------- .../app/src/main/res/values-pl/strings.xml | 28 ---------------- .../src/main/res/values-pt-rPT/strings.xml | 26 --------------- .../app/src/main/res/values-zh/strings.xml | 18 ---------- android/app/src/main/res/values/strings.xml | 14 -------- 7 files changed, 168 deletions(-) diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 76c3603371..d72f87c0af 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2428,33 +2428,6 @@ Stabil Im Aufbau Jeder Wert zeigt seine Verlässlichkeit. „Kalibrierung“ bedeutet, dass NOOP deine Basislinie noch lernt oder mehr Daten benötigt. „Im Aufbau“ bedeutet, dass genügend, aber noch wenige Daten vorliegen. „Stabil“ bedeutet, dass alle Eingaben vorhanden sind. Ist keine verlässliche Berechnung möglich, zeigt NOOP lieber keinen Wert als eine Schätzung. - %1$s – %2$s - Gelöschte Schlafzeiträume - Gelöschtes Schlaffenster ausgeblendet. Es bleibt gelöscht. - Guten Morgen! - Dieses gelöschte Schlaffenster konnte nicht ausgeblendet werden. Versuche es erneut. - Ausblenden - Dieses gelöschte Schlaffenster ausblenden - Diese Nacht konnte nicht wieder geöffnet werden. Versuche es erneut. - Wenn dieser Schlaf nur aus einem Import stammt und keine Rohdaten gespeichert sind, wird er möglicherweise nicht wieder angezeigt. - Vielleicht später - Bewegt euch sowieso - Bewegen Sie diesen Schlaf? - Nickerchen - Nächste Nacht - Noch keine frühere Nacht gespeichert. Frühere Nächte synchronisieren am Morgen. - Keine Nickerchen für diesen Tag aufgezeichnet. - Keine Etappenlücke für diese Nacht. - Keine Phasendaten für diese Nacht aufgezeichnet. - Offenes Journal - Vorherige Nacht - Berechne eine Nacht neu, um ihre Löschmarkierung zu entfernen und die verfügbaren Rohdaten erneut zu prüfen. - Diese gelöschte Schlafaufzeichnung neu berechnen - Diese Nacht neu berechnen - Wird neu berechnet… - Schlaf - Die Schlaferkennung wurde mit den für diese Nacht verfügbaren Daten erneut ausgeführt. - Phasen-Aufschlüsselung Trainingslast CTL · Kondition ATL · Ermüdung diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 36baf882e0..1f27dae7c6 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2414,28 +2414,6 @@ Sólido En desarrollo Cada puntuación indica su fiabilidad. «Calibrando» significa que NOOP aprende tu línea base o necesita más datos. «En desarrollo» indica que hay datos suficientes, pero aún limitados. «Sólido» indica que están presentes todos los datos. Si el cálculo no es fiable, NOOP no muestra una puntuación en vez de adivinar. - Experimental: a su propio riesgo - NOOP habla con el firmware de tu correa sobre un protocolo no oficial, de forma independiente. Existe un riesgo residual para el dispositivo, sus datos y su conexión con los servicios oficiales. Asumes ese riesgo. - No un dispositivo médico, no un consejo médico - Cada métrica es una aproximación no validada. No utilice NOOP para diagnosticar, tratar o tomar cualquier decisión de salud. Siempre consulte a un profesional calificado. - Ninguna garantía; limitación de responsabilidad - NOOP es gratuito y proporciona \"as es\", sin garantía. La responsabilidad se limita a la máxima medida que la ley que se aplica a usted permite, y nada aquí elimina las protecciones que su derecho local no nos deja eliminar. - Los términos completos están en TERMS.md, incluido con NOOP. Esto no es asesoría legal. - Por favor, confirme cada uno de estos: - No soy un empleado, contratista o afiliado de WHOOP, y no estoy usando NOOP en nombre de WHOOP. - Soy dueño del dispositivo WHOOP que usaré con NOOP y sólo lo usaré para acceder a mis propios datos. Hacerlo es mi decisión y mi riesgo, incluyendo cualquier efecto en mi cuenta WHOOP, suscripción, dispositivo o garantía, y puede violar los Términos de servicio de WHOOP\. - Entiendo que NOOP es no oficial y experimental, se proporciona gratuitamente y \"as es\" sin garantía, y no es un dispositivo médico o consejo médico. - En la medida en que la ley lo permita, no sostendré el proyecto NOOP ni sus colaboradores responsables de ninguna pérdida o daño derivado de mi uso. - He leído y acepto estos términos, y uso NOOP con mi propio dispositivo y mis propios datos, bajo mi propio riesgo. - Aceptar y continuar - El hilo de ti a lo largo del tiempo. - Últimos - Últimos %1$d días - Todo el historial - Tendencia - Tendencia: %1$s - Carga - Esfuerzo Carga de entrenamiento CTL · Forma física ATL · Fatiga diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 3a76751946..bfc9bf5496 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2413,39 +2413,6 @@ Fiable En cours Chaque score indique sa fiabilité. « Calibration en cours » signifie que NOOP apprend votre référence ou manque de données. « En cours » signifie que les données suffisent mais restent limitées. « Fiable » signifie que toutes les entrées sont présentes. Si le calcul n’est pas fiable, NOOP n’affiche rien plutôt que d’inventer. - Pas un dispositif médical, pas un avis médical - Chaque métrique est une approximation non validée. N\'utilisez pas NOOP pour diagnostiquer, traiter ou prendre une décision de santé. Consultez toujours un professionnel qualifié. - Aucune garantie ; responsabilité limitée - NOOP est gratuit et fourni \"as is\", sans garantie. La responsabilité est limitée dans la mesure maximale que la loi qui s\'applique à vous permet, et rien ici ne supprime les protections de votre loi locale ne nous permet pas de supprimer. - Les conditions complètes se trouvent dans TERMS.md, fourni avec NOOP. Ceci n\'est pas un conseil juridique. - Veuillez confirmer chacun de ces éléments : - Je ne suis pas un employé, entrepreneur ou affilié de WHOOP, et je n\'utilise pas NOOP au nom de WHOOP\. - Je possède l\'appareil WHOOP que je vais utiliser avec NOOP et je ne l\'utiliserai que pour accéder à mes propres données. Ce faisant est ma décision et mon risque, y compris tout effet sur mon compte WHOOP, abonnement, appareil, ou garantie, et il peut enfreindre les Conditions de service de WHOOP\. - Je comprends que NOOP est non officiel et expérimental, est fourni gratuitement et \"as is\" sans garantie, et n\'est pas un dispositif médical ou un conseil médical. - Dans toute la mesure permise par la loi, je ne tiendrai pas le projet NOOP ou ses contributeurs responsables de toute perte ou dommage découlant de mon utilisation de celui-ci. - J\'ai lu et j\'accepte ces conditions, et j\'utilise NOOP avec mon propre appareil et mes propres données, à mes propres risques. - Accepter et continuer - Le fil de vous au fil du temps. - Derniers - %1$d derniers jours - Tout l’historique - Tendance - Tendance : %1$s - Charge - Effort - Repos - Variabilité de la fréquence cardiaque - Fréquence cardiaque au repos - Aucune lecture cette semaine - Passez à une autre semaine avec les flèches ci-dessus pour voir son bilan. - Cette semaine - La semaine dernière - Semaine précédente - Semaine prochaine - Signaux quotidiens - Résumé de la semaine - Charge · Effort · Repos - Calendrier Charge d’entraînement CTL · Forme ATL · Fatigue diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 2317ddb6ee..338566cefc 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2419,34 +2419,6 @@ Pewny W trakcie Każdy wynik pokazuje wiarygodność. „Kalibracja” oznacza naukę wartości bazowej lub brak danych. „W trakcie” oznacza, że danych wystarcza, ale nadal jest ich niewiele. „Pewny” oznacza komplet danych. Gdy obliczenie nie jest wiarygodne, NOOP nie pokazuje wyniku zamiast zgadywać. - Przesunąć ten sen? - Drzemki - Następna noc - Nie zapisano jeszcze żadnej wcześniejszej nocy. Wcześniejsze noce synchronizują się rano. - W tym dniu nie zapisano żadnych drzemek. - Tego wieczoru nie będzie podziału na etapy. - Brak danych scenicznych zarejestrowanych dla tej nocy. - Otwórz dziennik - Poprzednia noc - Oblicz ponownie noc, aby usunąć znacznik usunięcia i ponownie przeskanuj dostępne surowe dane. - Oblicz ponownie tę usuniętą noc snu - Przelicz jeszcze raz tę noc - Przeliczam ponownie… - Sen - Wykrywanie snu zostało ponownie przeprowadzone przy użyciu danych dostępnych dla tej nocy. - Podział sceny - Spowoduje to przeniesienie nocy do czasu, w którym nie ma zarejestrowanych danych. Nie można tam wyprowadzić etapów, więc może być wyświetlany jako pusty, dopóki nie pokryją go dane. - Dane z Twojej nocy są już dostępne. Rejestrowanie Twojego samopoczucia pomaga NOOP dowiedzieć się, co zapewnia najlepszą regenerację. - Równowaga autonomiczna (LF/HF) - średnio - Indeks stresu Baevsky\'ego - Codziennie 0-3 proxy - Moc wysokiej częstotliwości - Nie ma jeszcze wystarczającej liczby ostatnich dni, aby wyznaczyć trend. Noś pasek, aby go zapełnić. - z 3 - na żądanie · dzisiejszy R-R - w skali obciążenia autonomicznego 0-3 - Linia śledzi obciążenie autonomiczne w ciągu dnia, punktowane Obciążenie treningowe CTL · Kondycja ATL · Zmęczenie diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index eb83a1ff92..4c4258a462 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2407,32 +2407,6 @@ Sólido Em formação Cada pontuação indica a fiabilidade. «A calibrar» significa que o NOOP aprende a tua linha de base ou precisa de mais dados. «Em formação» indica dados suficientes, mas ainda limitados. «Sólido» indica todos os dados presentes. Se o cálculo não for fiável, o NOOP não mostra uma pontuação em vez de adivinhar. - Nenhum cochilo registado neste dia. - Sem quebra de palco para esta noite. - Sem dados de palco registados para esta noite. - Diário aberto - Noite anterior - Recalcule uma noite para limpar o teu marcador de eliminação e verifique novamente os dados brutos disponíveis. - Recalcule esta noite de sono excluída - Recalcular esta noite - Recalculando… - Sono - A deteção do sono foi novamente executada utilizando os dados disponíveis para esta noite. - Análise do estágio - Isto move a noite para um horário sem dados registados. Os estágios não podem ser derivados aí, pelo que pode aparecer como vazio até que os dados o cubram. - Os teus dados noturnos estão disponíveis. Registar como se sentiu ajuda o NOOP a aprender o que impulsiona a tua melhor recuperação. - Equilíbrio autónomo (LF/HF) - avg - Índice de Stress Baevsky - Procuração diária 0-3 - Potência de alta frequência - Ainda não há dias suficientes para traçar uma tendência. Continue a usar a tua bracelete para preenchê-la. - de 3 - on demand · R-R de hoje - na escala de carga autónoma 0-3 - A linha acompanha a tua carga autónoma ao longo do dia, pontuada - Estas são lentes HRV extra a pedido, calculadas a partir dos intervalos RR atuais. São informativos e não alteram a pontuação de stress acima. - Comparámos a frequência cardíaca em repouso e a VFC de hoje com a tua frequência cardíaca de 30 dias Carga de treino CTL · Forma física ATL · Fadiga diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index c1e2e43013..88252f8de9 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2336,24 +2336,6 @@ 可靠 建立中 每项评分都会显示可靠性。“校准中”表示 NOOP 正在学习基线或需要更多数据;“建立中”表示数据足以评分但仍有限;“可靠”表示输入齐全。如果无法可靠计算,NOOP 会留空而不是猜测。 - 卡片背后的天空 - 睡眠的分类、评分、记录机制,以及您的数据从何而来。 - 睡眠分期 V2 算法 - 睡眠分期 (V2 算法) - 部分手机还需要手动开启“自启动”权限 — 请前往系统设置中开启 - 步数校准 - 步数估算校准功能。%1$s。点击打开校准界面。 - 步数估算 - 手环设备 - 手环名称 - 压力签到提醒 (震动) - 支持与联系 - 温度 - 测试中心 - 主题 - 这是一个未加密的普通压缩包——任何拿到文件的人都能用任意压缩工具打开它。请把它存放在你信任的地方。 - 这将重新启动大约需要 4 个晚上的充能(Charge)与 HRV 基准建立过程。您的历史数据都会被保留下来。如果因为您第一周佩戴时状态不佳(比如生病),导致基线严重偏离了正常水平,请使用此功能。 - 趋势图表 移除此次睡眠并重新计算当天数据。之后几秒内可以撤销。 移除此次已记录的睡眠并重新计算当天数据。NOOP 不会在此时间段内重新检测睡眠。之后几秒内可以撤销。 可选 · VO₂max 由约 4 晚的心率数据构建;腰围可让它更准确。健康年龄本身并不需要它。请在肚脐高度绕腰一周测量。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a6f0e71128..b9550d6763 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2440,20 +2440,6 @@ Solid Building Each score shows its reliability. Calibrating means NOOP is learning your baseline or needs more data. Building means enough data exists for a score, but it is still limited. Solid means all inputs are present. If NOOP cannot calculate a score reliably, it shows no score instead of guessing. - This restarts the roughly 4-night build-up for Charge and your HRV baseline. Your history stays. Use it if a bad first week, like wearing it while sick, set your baseline off. - Trend charts - Try WHOOP 5/MG protocol probes - Units - Unlock WHOOP 5/MG deep data (R22) - Unlock WHOOP 5/MG deep data - Version %1$s is available - Waist (optional) - Weight - What each strap can read, and why some features differ. - What\'s new - What\'s new in NOOP %1$s - Whole night is NOOP\'s default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores your recent nights over the new window and takes effect right away once you have a few nights of data. - WHOOP 4.0 versus 5.0: what each can read and why Training Load CTL · Fitness ATL · Fatigue From 614cf41d7960463546d54f19c7d556ccd46324c6 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 12:12:03 +0200 Subject: [PATCH 06/12] Address upstream localization review --- Strand/Resources/Localizable.xcstrings | 248 +++++++++--------- Tools/test_home_i18n.py | 24 ++ .../com/noop/analytics/ReadinessEngine.kt | 110 ++++---- .../src/main/java/com/noop/ui/TodayScreen.kt | 61 ++++- .../app/src/main/res/values-fr/strings.xml | 6 +- 5 files changed, 267 insertions(+), 182 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 9dc6168cda..93b4946ac7 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1578,7 +1578,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "%@ Anstrengung" + "value": "%@ Belastung" } }, "es": { @@ -6647,7 +6647,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "%1$@: %2$@ der Ladung" + "value": "%1$@: %2$@ der Energie" } }, "en": { @@ -13372,7 +13372,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "%lld%% Ladung" + "value": "%lld%% Energie" } }, "es": { @@ -20666,7 +20666,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Eine niedrigere Ruheherzfrequenz bedeutet meist eine höhere Ladung." + "value": "Eine niedrigere Ruheherzfrequenz bedeutet meist eine höhere Energie." } }, "es": { @@ -21478,7 +21478,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Einmal täglich ein Hinweis, wenn deine Anstrengung das untere Ende des heutigen optimalen Bereichs erreicht, berechnet aus deiner Erholung." + "value": "Einmal täglich ein Hinweis, wenn deine Belastung das untere Ende des heutigen optimalen Bereichs erreicht, berechnet aus deiner Erholung." } }, "es": { @@ -21815,7 +21815,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ein Read-Only Plews / Altini am kleinsten Wert während der Änderung Ihrer nächtlichen HRV: Es zeigt an, ob Ihre 7-Nächte-HRV-Basislinie über, innerhalb oder unter Ihrem persönlichen normalen Band liegt. Es ändert nichts anderes - der Ladungsring ist identisch, ob dieser ein- oder ausgeschaltet ist. Dies ist eine grobe / frühe Prüfung, die noch nicht gegen unterschiedliche reale Daten (n = 1) validiert wurde." + "value": "Ein Read-Only Plews / Altini am kleinsten Wert während der Änderung Ihrer nächtlichen HRV: Es zeigt an, ob Ihre 7-Nächte-HRV-Basislinie über, innerhalb oder unter Ihrem persönlichen normalen Band liegt. Es ändert nichts anderes - der Energiering ist identisch, ob dieser ein- oder ausgeschaltet ist. Dies ist eine grobe / frühe Prüfung, die noch nicht gegen unterschiedliche reale Daten (n = 1) validiert wurde." } }, "es": { @@ -22197,7 +22197,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ein einfaches Ziel, das sich deiner Anstrengung anpasst. Allgemeine Wellness-Orientierung, kein medizinischer Rat." + "value": "Ein einfaches Ziel, das sich deiner Belastung anpasst. Allgemeine Wellness-Orientierung, kein medizinischer Rat." } }, "es": { @@ -22644,7 +22644,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Eine Uhr misst deine Herzfrequenzvariabilität stichprobenartig, statt sie die ganze Nacht zu streamen, daher braucht dein Ladungswert etwa sieben Nächte zum Kalibrieren. Bis dahin zeigt NOOP „braucht mehr Daten“, nie eine geratene Zahl." + "value": "Eine Uhr misst deine Herzfrequenzvariabilität stichprobenartig, statt sie die ganze Nacht zu streamen, daher braucht dein Energiewert etwa sieben Nächte zum Kalibrieren. Bis dahin zeigt NOOP „braucht mehr Daten“, nie eine geratene Zahl." } }, "es": { @@ -25928,7 +25928,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Fügt ein einfaches Flüssigkeitsprotokoll mit einem Tagesziel hinzu, das sich deiner Anstrengung anpasst. Tippe, um einen Schluck, eine Tasse oder eine Flasche zu erfassen, und sieh zu, wie sich der Fortschrittsring füllt. Nur auf %@. Nichts wird synchronisiert." + "value": "Fügt ein einfaches Flüssigkeitsprotokoll mit einem Tagesziel hinzu, das sich deiner Belastung anpasst. Tippe, um einen Schluck, eine Tasse oder eine Flasche zu erfassen, und sieh zu, wie sich der Fortschrittsring füllt. Nur auf %@. Nichts wird synchronisiert." } }, "es": { @@ -29472,7 +29472,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Apples eigene Schlafphasen fließen direkt in Ruhe ein. Das ist eine der Stärken der Uhr." + "value": "Apples eigene Schlafphasen fließen direkt in die Erholung ein. Das ist eine der Stärken der Uhr." } }, "es": { @@ -29530,7 +29530,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Apples Schlafphasen sind stark und fließen direkt in deinen Ruhewert ein." + "value": "Apples Schlafphasen sind zuverlässig und fließen direkt in deinen Erholungswert ein." } }, "es": { @@ -30026,7 +30026,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Frage nach deiner Ladung, Anstrengung, Ruhe und deinen Workouts, basierend auf deinen eigenen Zahlen." + "value": "Frage nach deiner Energie, Belastung, Erholung und deinen Workouts, basierend auf deinen eigenen Zahlen." } }, "es": { @@ -32078,7 +32078,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ø-HF wird wie eingegeben angezeigt. HF-Graph, Zonen und Anstrengung bleiben aus der aufgezeichneten Sitzung." + "value": "Die Ø-HF wird wie eingegeben angezeigt. HF-Graph, Zonen und Belastung bleiben aus der aufgezeichneten Sitzung." } }, "es": { @@ -43191,7 +43191,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung" + "value": "Energie" } }, "en": { @@ -43255,7 +43255,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung %@%%" + "value": "Energie %@%%" } }, "es": { @@ -43371,7 +43371,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung (gesamter Verlauf)" + "value": "Energie (gesamter Verlauf)" } }, "es": { @@ -43429,7 +43429,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung (letztes Jahr)" + "value": "Energie (letztes Jahr)" } }, "es": { @@ -43487,7 +43487,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladungs-Basislinie wird kalibriert. %1$@, %2$@. %3$@." + "value": "Energie-Basislinie wird kalibriert. %1$@, %2$@. %3$@." } }, "en": { @@ -43551,7 +43551,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladungs-Basislinie wird neu kalibriert" + "value": "Energie-Basislinie wird neu kalibriert" } }, "es": { @@ -43609,7 +43609,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist niedrig und der Schlaf war konsistent." + "value": "Die Energie ist niedrig und der Schlaf war konsistent." } }, "es": { @@ -43667,7 +43667,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist niedrig, aber der Schlaf war zu kurz." + "value": "Die Energie ist niedrig, aber der Schlaf war zu kurz." } }, "es": { @@ -43725,7 +43725,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist niedrig." + "value": "Die Energie ist niedrig." } }, "es": { @@ -43783,7 +43783,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist stabil und der Schlaf war konsistent." + "value": "Die Energie ist stabil und der Schlaf war konsistent." } }, "es": { @@ -43841,7 +43841,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist stabil, aber der Schlaf war zu kurz." + "value": "Die Energie ist stabil, aber der Schlaf war zu kurz." } }, "es": { @@ -43899,7 +43899,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist stabil." + "value": "Die Energie ist stabil." } }, "es": { @@ -43957,7 +43957,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist stark und der Schlaf war konsistent." + "value": "Die Energie ist stark und der Schlaf war konsistent." } }, "es": { @@ -44015,7 +44015,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist stark, aber der Schlaf war zu kurz." + "value": "Die Energie ist stark, aber der Schlaf war zu kurz." } }, "es": { @@ -44073,7 +44073,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung ist stark." + "value": "Die Energie ist stark." } }, "es": { @@ -44131,7 +44131,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladungsmodell" + "value": "Energiemodell" } }, "es": { @@ -44247,7 +44247,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladungstrend" + "value": "Energietrend" } }, "es": { @@ -44305,7 +44305,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung wiegt Ihre HRV gegen Ihre persönliche Grundlinie (~ 55%%), Ruheherzfrequenz (~ 20%%), Ruhequalität (~ 15%%), Atmung (~ 5%%) und Hauttemperaturabweichung (~ 5%%). Anstrengung ist eine 0-%lld kardiovaskuläre Belastung aus der Zeit in Herzfrequenzzonen. Ruhe wird von Bewegung und Herzfrequenz inszeniert. Alles wird hier aus den Rohdaten des Bandes berechnet. Es funktioniert für jeden Tag, an dem NOOP Rohströme gesammelt hat." + "value": "Energie gewichtet Ihre HRV gegenüber Ihrer persönlichen Basislinie (~55%%), Ruheherzfrequenz (~20%%), Erholungsqualität (~15%%), Atmung (~5%%) und Hauttemperaturabweichung (~5%%). Belastung ist eine kardiovaskuläre Belastung von 0–%lld aus der Zeit in Herzfrequenzzonen. Erholung wird aus Bewegung und Herzfrequenz in Schlafphasen eingeteilt. Alles wird hier aus den Rohdaten des Straps berechnet." } }, "es": { @@ -44339,7 +44339,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ladung gewichtet deine HRV gegenüber deiner persönlichen Basislinie (~55%%), Ruheherzfrequenz (~20%%), Ruhequalität (~15%%), Atmung (~5%%) und Hauttemperatur-Abweichung (~5%%). Anstrengung ist eine kardiovaskuläre Last von 0–%@ aus der Zeit in Herzfrequenzzonen. Ruhe wird aus Bewegung und Herzfrequenz in Phasen eingeteilt. Alles wird hier aus den Rohdaten des Straps berechnet. Es funktioniert für jeden Tag, an dem NOOP Roh-Streams gesammelt hat." + "value": "Die Energie gewichtet deine HRV gegenüber deiner persönlichen Basislinie (~55%%), Ruheherzfrequenz (~20%%), Erholungsqualität (~15%%), Atmung (~5%%) und Hauttemperatur-Abweichung (~5%%). Belastung ist eine kardiovaskuläre Last von 0–%@ aus der Zeit in Herzfrequenzzonen. Erholung wird aus Bewegung und Herzfrequenz in Schlafphasen eingeteilt. Alles wird hier aus den Rohdaten des Straps berechnet. Es funktioniert für jeden Tag, an dem NOOP Roh-Streams gesammelt hat." } }, "es": { @@ -44455,7 +44455,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung · %@ Pkt." + "value": "Energie · %@ Pkt." } }, "es": { @@ -44513,7 +44513,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung · Anstrengung · Ruhe" + "value": "Energie · Belastung · Erholung" } }, "es": { @@ -44571,7 +44571,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung → Ladung am Folgetag" + "value": "Energie → Energie am Folgetag" } }, "es": { @@ -44629,7 +44629,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung, Anstrengung und Ruhe (und wie sie sich von WHOOP unterscheiden)." + "value": "Energie, Belastung und Erholung (und wie sie sich von WHOOP unterscheiden)." } }, "es": { @@ -44687,7 +44687,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung, Anstrengung und Ruhe werden auf deinem eigenen Gerät aus deinen Strap-Daten berechnet. Die Ladung braucht etwa vier Nächte Schlaf, um deine Basislinie zu lernen (das ist „Kalibrierung“, auf dem Ring als Nächte von 4 gezählt), und wird über deine ersten Wochen immer genauer. Auf einem WHOOP 5 oder MG speichert der Strap wenig Verlauf, dieser Zähler kann also auf 0 von 4 stehen, bis du ihn ein paar Nächte getragen hast. Das ist die Sync-Grenze des Straps, kein Fehler. Bevor es eine Zahl gibt, zeigt NOOP, was es kann, ohne eine zu erfinden." + "value": "Energie, Belastung und Erholung werden auf deinem eigenen Gerät aus deinen Strap-Daten berechnet. Die Energie braucht etwa vier Nächte Schlaf, um deine Basislinie zu lernen (das ist „Kalibrierung“, auf dem Ring als Nächte von 4 gezählt), und wird über deine ersten Wochen immer genauer. Auf einem WHOOP 5 oder MG speichert der Strap wenig Verlauf, dieser Zähler kann also auf 0 von 4 stehen, bis du ihn ein paar Nächte getragen hast. Das ist die Sync-Grenze des Straps, kein Fehler. Bevor es eine Zahl gibt, zeigt NOOP, was es kann, ohne eine zu erfinden." } }, "es": { @@ -44745,7 +44745,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung, Anstrengung und Ruhe werden nach ein paar Nächten Tragen persönlich." + "value": "Energie, Belastung und Erholung werden nach ein paar Nächten Tragen persönlich." } }, "es": { @@ -44803,7 +44803,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung, Anstrengung und Ruhe bauen sich über die nächsten Nächte am Handgelenk auf." + "value": "Energie, Belastung und Erholung bauen sich über die nächsten Nächte am Handgelenk auf." } }, "es": { @@ -44861,7 +44861,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung, Anstrengung, Ruhe, HRV, Ruhe- und Live-Herzfrequenz sowie Strap-Akku auf einen Blick." + "value": "Energie, Belastung, Erholung, HRV, Ruhe- und Live-Herzfrequenz sowie Strap-Akku auf einen Blick." } }, "es": { @@ -44919,7 +44919,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung: Wie erholt bist du?" + "value": "Energie: Wie gut bist du erholt?" } }, "es": { @@ -46033,7 +46033,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wähle, wie Distanzen, Gewichte, Größen, Temperaturen und Anstrengung angezeigt werden. Deine Daten werden immer gleich gespeichert. Das ändert nur die Anzeige." + "value": "Wähle, wie Distanzen, Gewichte, Größen, Temperaturen und Belastung angezeigt werden. Deine Daten werden immer gleich gespeichert. Das ändert nur die Anzeige." } }, "es": { @@ -53285,7 +53285,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Aktuelle Ladung" + "value": "Aktuelle Energie" } }, "es": { @@ -62627,7 +62627,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anstrengung" + "value": "Belastung" } }, "en": { @@ -62691,7 +62691,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anstrengung (0–100)" + "value": "Belastung (0–100)" } }, "es": { @@ -62749,7 +62749,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anstrengungsskala" + "value": "Belastungsskala" } }, "es": { @@ -62807,7 +62807,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anstrengung · %@" + "value": "Belastung · %@" } }, "es": { @@ -62865,7 +62865,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anstrengung: Wie hart hat dein Herz gearbeitet?" + "value": "Belastung: Wie stark hat dein Herz gearbeitet?" } }, "es": { @@ -64270,7 +64270,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Schätzung aus der heutigen Anstrengung, deinem typischen Schlaf und deiner Erholungs-Basislinie aus %lld Nächten, keine Messung. Deine echte Ladung wird aus der HRV von morgen berechnet, wenn du aufwachst." + "value": "Schätzung aus der heutigen Belastung, deinem typischen Schlaf und deiner Erholungs-Basislinie aus %lld Nächten, keine Messung. Deine echte Energie wird aus der HRV von morgen berechnet, wenn du aufwachst." } }, "es": { @@ -73161,7 +73161,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "HF %@ · Anstrengung %@" + "value": "HF %@ · Belastung %@" } }, "es": { @@ -74777,7 +74777,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "HRV ↔ Ladung" + "value": "HRV ↔ Energie" } }, "es": { @@ -76834,7 +76834,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Herzfrequenzvariabilität als Motor hinter deinem Ladungswert." + "value": "Herzfrequenzvariabilität als Motor hinter deinem Energiewert." } }, "es": { @@ -78418,7 +78418,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "So wird Ladung berechnet" + "value": "So wird Energie berechnet" } }, "en": { @@ -78482,7 +78482,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "So wird Ladung berechnet. Die Methode hinter dem Wert." + "value": "So wird Energie berechnet. Die Methode hinter dem Wert." } }, "es": { @@ -78540,7 +78540,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "So werden Ladung, Anstrengung und Ruhe berechnet, und so unterscheiden sie sich von WHOOP." + "value": "So werden Energie, Belastung und Erholung berechnet, und so unterscheiden sie sich von WHOOP." } }, "es": { @@ -78598,7 +78598,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "So wird Anstrengung berechnet" + "value": "So wird Belastung berechnet" } }, "en": { @@ -78778,7 +78778,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "So wird Ruhe berechnet" + "value": "So wird Erholung berechnet" } }, "en": { @@ -79203,7 +79203,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wie eng eine gute Nacht der Ladung am nächsten Morgen folgt." + "value": "Wie eng eine gute Nacht der Energie am nächsten Morgen folgt." } }, "es": { @@ -79261,7 +79261,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wie viel der Ladung eines Tages in den nächsten hinüberträgt." + "value": "Wie viel der Energie eines Tages in den nächsten hinüberträgt." } }, "es": { @@ -80177,7 +80177,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wie entwickelt sich meine Ladung?" + "value": "Wie entwickelt sich meine Energie?" } }, "es": { @@ -82816,7 +82816,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Importiere deinen Krafttrainings-Verlauf aus einem Hevy-CSV-Export oder einem Liftosaur-JSON-Export. Jedes Workout wird eine Kraftsitzung mit einer Trainingsvolumen-Schätzung (Gewicht × Wiederholungen). Es ist eine Volumenzahl, keine gemessene Belastung. Sie ändert nie deine Anstrengung." + "value": "Importiere deinen Krafttrainings-Verlauf aus einem Hevy-CSV-Export oder einem Liftosaur-JSON-Export. Jedes Workout wird eine Kraftsitzung mit einer Trainingsvolumen-Schätzung (Gewicht × Wiederholungen). Es ist eine Volumenzahl, keine gemessene Belastung. Sie ändert nie deine Belastung." } }, "es": { @@ -87285,7 +87285,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Letzte Ladungs-Aufschlüsselung" + "value": "Letzte Energie-Aufschlüsselung" } }, "es": { @@ -89421,7 +89421,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Angeführt von deiner Herzfrequenzvariabilität (HRV) gegenüber deiner persönlichen Basislinie, plus Ruheherzfrequenz, Ruhe der letzten Nacht, Atemfrequenz und einem Hauttemperatur-Signal (ein frühes Zeichen für Krankheit oder Überlastung). Höhere HRV gegenüber deiner Basislinie bedeutet mehr Ladung. NOOP braucht zuerst ein paar Nächte, um deine Basislinie zu lernen. Bis dahin siehst du „Kalibrierung“." + "value": "Angeführt von deiner Herzfrequenzvariabilität (HRV) gegenüber deiner persönlichen Basislinie, plus Ruheherzfrequenz, Erholung der letzten Nacht, Atemfrequenz und einem Hauttemperatur-Signal (ein frühes Zeichen für Krankheit oder Überlastung). Höhere HRV gegenüber deiner Basislinie bedeutet mehr Energie. NOOP braucht zuerst ein paar Nächte, um deine Basislinie zu lernen. Bis dahin siehst du „Kalibrierung“." } }, "es": { @@ -91474,7 +91474,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Live-Gerätedaten über Bluetooth FTMS. Kein Schlaf, keine Erholung, keine Hauttemperatur, kein SpO₂. Anstrengung braucht die Herzfrequenz des Geräts; ohne sie protokolliert die Sitzung nur die Gerätemetriken." + "value": "Live-Gerätedaten über Bluetooth FTMS. Kein Schlaf, keine Erholung, keine Hauttemperatur, kein SpO₂. Belastung braucht die Herzfrequenz des Geräts; ohne sie protokolliert die Sitzung nur die Gerätemetriken." } }, "es": { @@ -92728,7 +92728,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Erfasse oben Verhaltensweisen. Nach ein paar Tagen mit Antworten stuft NOOP ein, wie jedes davon deine Ladung, HRV und Ruhe bewegt. Der Import eines WHOOP-Exports (der sein Tagebuch enthält) füllt den Verlauf sofort rückwirkend." + "value": "Erfasse oben Verhaltensweisen. Nach ein paar Tagen mit Antworten stuft NOOP ein, wie jedes davon deine Energie, HRV und Erholung bewegt. Der Import eines WHOOP-Exports (der sein Tagebuch enthält) füllt den Verlauf sofort rückwirkend." } }, "es": { @@ -100221,7 +100221,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "NOOP-Ladung" + "value": "NOOP-Energie" } }, "es": { @@ -100337,7 +100337,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "NOOP kann allein mit einer Apple Watch laufen, kein Brustgurt nötig. Die Uhr ist der Sensor; NOOP übernimmt das Denken auf deinem Telefon und berechnet Ladung, Ruhe, Anstrengung und dein Fitnessalter aus deinen Health-Daten, alles auf dem Gerät." + "value": "NOOP kann allein mit einer Apple Watch laufen, kein Brustgurt nötig. Die Uhr ist der Sensor; NOOP übernimmt das Denken auf deinem Telefon und berechnet Energie, Erholung, Belastung und dein Fitnessalter aus deinen Health-Daten, alles auf dem Gerät." } }, "es": { @@ -100603,7 +100603,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "NOOP gibt dir drei Tageswerte (Ladung, Anstrengung und Ruhe), jeweils auf einer Skala von 0–100. Sie entstehen aus den Rohsignalen deines Straps mit veröffentlichter, begutachteter Sportwissenschaft und werden vollständig auf deinem Gerät berechnet. Es sind NICHT WHOOPs Werte: Wir haben WHOOPs private Algorithmen nicht und tun auch nicht so. Sie zielen mit offener Wissenschaft auf dieselben drei Fragen, folgen WHOOPs Werten also meist in der Richtung, stimmen aber nicht Zahl für Zahl überein. Und genau das ist der Punkt." + "value": "NOOP gibt dir drei Tageswerte (Energie, Belastung und Erholung), jeweils auf einer Skala von 0–100. Sie entstehen aus den Rohsignalen deines Straps mit veröffentlichter, begutachteter Sportwissenschaft und werden vollständig auf deinem Gerät berechnet. Es sind NICHT WHOOPs Werte: Wir haben WHOOPs private Algorithmen nicht und tun auch nicht so. Sie zielen mit offener Wissenschaft auf dieselben drei Fragen, folgen WHOOPs Werten also meist in der Richtung, stimmen aber nicht Zahl für Zahl überein. Und genau das ist der Punkt." } }, "es": { @@ -101826,7 +101826,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "NOOP berechnet deine Ladung, Anstrengung und Ruhe selbst: auf dem Gerät, keine Cloud." + "value": "NOOP berechnet deine Energie, Belastung und Erholung selbst: auf dem Gerät, keine Cloud." } }, "es": { @@ -102466,7 +102466,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "NOOPs eigene Ladung, Anstrengung und Ruhe, berechnet auf deinem Gerät nach veröffentlichten Methoden." + "value": "NOOPs eigene Energie, Belastung und Erholung, berechnet auf deinem Gerät nach veröffentlichten Methoden." } }, "es": { @@ -104528,7 +104528,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Noch keine Ladungs-Aufschlüsselung" + "value": "Noch keine Energie-Aufschlüsselung" } }, "es": { @@ -105230,7 +105230,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Noch keine Cardio-Last. Anstrengung baut sich auf, sobald deine Herzfrequenz in deine Anstrengungszone steigt (etwa 50 % deiner Herzfrequenzreserve). Ein ruhiger Tag liest sich ehrlich nahe null." + "value": "Noch keine Cardio-Last. Belastung baut sich auf, sobald deine Herzfrequenz in deine Belastungszone steigt (etwa 50 % deiner Herzfrequenzreserve). Ein ruhiger Tag liest sich ehrlich nahe null." } }, "es": { @@ -105288,7 +105288,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Kein Brustgurt? Kein Problem. NOOP kann allein mit deiner Apple Watch laufen. Es liest die Daten deiner Uhr über Apple Health und berechnet deine Ladung, Ruhe, Anstrengung und dein Fitnessalter direkt hier auf deinem Telefon. Alles bleibt auf dem Gerät." + "value": "Kein Brustgurt? Kein Problem. NOOP kann allein mit deiner Apple Watch laufen. Es liest die Daten deiner Uhr über Apple Health und berechnet deine Energie, Erholung, Belastung und dein Fitnessalter direkt hier auf deinem Telefon. Alles bleibt auf dem Gerät." } }, "es": { @@ -111863,7 +111863,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Benachrichtigen, wenn die optimale Anstrengung erreicht ist" + "value": "Benachrichtigen, wenn die optimale Belastung erreicht ist" } }, "es": { @@ -113801,7 +113801,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "An: Deine Ladung, Ruhe, HRV und Workouts werden für maßgeschneidertes Coaching mit dem Anbieter geteilt." + "value": "An: Deine Energie, Erholung, HRV und Workouts werden für maßgeschneidertes Coaching mit dem Anbieter geteilt." } }, "es": { @@ -115490,7 +115490,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Optimale Anstrengung erreicht" + "value": "Optimale Belastung erreicht" } }, "es": { @@ -119452,7 +119452,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wähle eine Sportart. NOOP zeichnet HF, Spitze, Durchschnitt und Anstrengung aus dem Live-Feed auf." + "value": "Wähle eine Sportart. NOOP zeichnet HF, Spitze, Durchschnitt und Belastung aus dem Live-Feed auf." } }, "es": { @@ -119510,7 +119510,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Wähle ein Wearable-Signal (Ruhe-HF, HRV, Schlaf, Ladung, Gewicht …), um es gegen diesen Marker zu stellen. NOOP mittelt das Signal über die %@ vor jeder Messung." + "value": "Wähle ein Wearable-Signal (Ruhe-HF, HRV, Schlaf, Energie, Gewicht …), um es gegen diesen Marker zu stellen. NOOP mittelt das Signal über die %@ vor jeder Messung." } }, "es": { @@ -120714,7 +120714,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Speist Ladung, Anstrengung, Ruhe und Schlaf" + "value": "Speist Energie, Belastung, Erholung und Schlaf" } }, "es": { @@ -120772,7 +120772,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Speist Ladung, Anstrengung, Ruhe, Schlaf + Gesundheitsmonitor" + "value": "Speist Energie, Belastung, Erholung, Schlaf + Gesundheitsmonitor" } }, "es": { @@ -120830,7 +120830,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Speist jetzt Anstrengung; Ladung und Ruhe, sobald genug Nächte und die Dekodierung bestätigt sind" + "value": "Speist jetzt Belastung; Energie und Erholung, sobald genug Nächte und die Dekodierung bestätigt sind" } }, "es": { @@ -120888,7 +120888,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Speist Ruhe, Anstrengung, Fitnessalter und Schritte, plus Ladung, sobald die Erholung kalibriert ist" + "value": "Speist Erholung, Belastung, Fitnessalter und Schritte sowie Energie, sobald die Basislinie kalibriert ist" } }, "es": { @@ -120946,7 +120946,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Speist die Live-Konsole + Anstrengung. Keine Ladung, keine Ruhe, kein Schlaf" + "value": "Speist die Live-Konsole + Belastung. Keine Energie, keine Erholung, kein Schlaf" } }, "es": { @@ -124445,7 +124445,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Eingestuft, mit Zeitversatz: welche deiner Gewohnheiten deine Ladung wirklich bewegen, plus deine persönliche Alkohol-/Koffein-Dosis-Wirkung." + "value": "Eingestuft, mit Zeitversatz: welche deiner Gewohnheiten deine Energie wirklich bewegen, plus deine persönliche Alkohol-/Koffein-Dosis-Wirkung." } }, "es": { @@ -124678,7 +124678,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Verankert jede Basislinie, die in die Ladung einfließt, neu an deinen letzten Nächten. Kein gespeicherter Tag wird gelöscht." + "value": "Verankert jede Basislinie, die in die Energie einfließt, neu an deinen letzten Nächten. Kein gespeicherter Tag wird gelöscht." } }, "es": { @@ -126424,7 +126424,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Bereit für eine deutliche Anstrengung." + "value": "Bereit für eine deutliche Belastung." } }, "es": { @@ -126870,7 +126870,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladungs-Basislinie neu kalibrieren" + "value": "Energie-Basislinie neu kalibrieren" } }, "es": { @@ -126928,7 +126928,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Deine Ladungs-Basislinie neu kalibrieren?" + "value": "Deine Energie-Basislinie neu kalibrieren?" } }, "es": { @@ -128106,7 +128106,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Zeichnet ein Live-Geräte-Workout auf, Anstrengung aus der HF bewertet, wenn das Gerät sie meldet" + "value": "Zeichnet ein Live-Geräte-Workout auf, Belastung aus der HF bewertet, wenn das Gerät sie meldet" } }, "es": { @@ -128228,7 +128228,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Erholung / Ladung" + "value": "Erholung / Energie" } }, "es": { @@ -128443,7 +128443,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Erholung, NOOPs Ladungswert, wird von deiner Herzfrequenzvariabilität gegenüber deiner persönlichen Basislinie angeführt. Ein Brustgurt streamt Schlag-zu-Schlag-Daten die ganze Nacht dicht, er kann diese Basislinie also schnell lernen. Eine Apple Watch misst HRV dagegen stichprobenartig, eine Handvoll Messungen über den Tag plus nachts – das Signal ist echt, aber dünner." + "value": "Die Erholung, NOOPs Energiewert, wird von deiner Herzfrequenzvariabilität gegenüber deiner persönlichen Basislinie angeführt. Ein Brustgurt streamt Schlag-zu-Schlag-Daten die ganze Nacht dicht, er kann diese Basislinie also schnell lernen. Eine Apple Watch misst HRV dagegen stichprobenartig, eine Handvoll Messungen über den Tag plus nachts – das Signal ist echt, aber dünner." } }, "es": { @@ -128559,7 +128559,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Erholung, Anstrengung und Schlaf auf einen Blick" + "value": "Erholung, Belastung und Schlaf auf einen Blick" } }, "es": { @@ -132045,7 +132045,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ruhe" + "value": "Erholung" } }, "en": { @@ -132341,7 +132341,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ruhe ↔ Ladung" + "value": "Ruhe ↔ Energie" } }, "es": { @@ -132399,7 +132399,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ruhe: Wie erholsam war dein Schlaf?" + "value": "Erholung: Wie erholsam war dein Schlaf?" } }, "es": { @@ -132669,7 +132669,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Startet den etwa 4-nächtigen Aufbau für Ladung und deine HRV-Basislinie ab heute Nacht neu. Nutze es, wenn eine schlechte erste Woche deine Basislinie verzerrt hat. Dein Verlauf bleibt." + "value": "Startet den etwa 4-nächtigen Aufbau für Energie und deine HRV-Basislinie ab heute Nacht neu. Nutze es, wenn eine schlechte erste Woche deine Basislinie verzerrt hat. Dein Verlauf bleibt." } }, "es": { @@ -132849,7 +132849,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ruhe-HF ↔ Ladung" + "value": "Ruhe-HF ↔ Energie" } }, "es": { @@ -133145,7 +133145,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Ruheherzfrequenz läuft hoch gegenüber deiner Norm. Dein Körper steht heute unter Last. Halte die Anstrengung leicht." + "value": "Die Ruheherzfrequenz läuft hoch gegenüber deiner Norm. Dein Körper steht heute unter Last. Halte die Belastung leicht." } }, "es": { @@ -139227,7 +139227,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Sieh, wie Ladung, Anstrengung und Ruhe berechnet werden und wie sie sich von WHOOP unterscheiden." + "value": "Sieh, wie Energie, Belastung und Erholung berechnet werden und wie sie sich von WHOOP unterscheiden." } }, "es": { @@ -139343,7 +139343,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Sieh, was deine Ladung geprägt hat" + "value": "Sieh, was deine Energie geprägt hat" } }, "es": { @@ -145089,7 +145089,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Schlafstetigkeit: Ruhe schwankte ±%@ Pkt. von Nacht zu Nacht." + "value": "Schlafstabilität: Erholung schwankte von Nacht zu Nacht um ±%@ Punkte." } }, "es": { @@ -147635,7 +147635,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Starte ein Workout, wenn der Stream zählt. NOOP zeichnet Intervall, HF, Spitze, Durchschnitt und Anstrengung aus demselben Live-Feed auf." + "value": "Starte ein Workout, wenn der Stream zählt. NOOP zeichnet Intervall, HF, Spitze, Durchschnitt und Belastung aus demselben Live-Feed auf." } }, "es": { @@ -149051,7 +149051,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Schritte, aktive Energie und erfasste Workouts fließen in die Anstrengung ein. Dicht und verlässlich." + "value": "Schritte, aktive Energie und erfasste Workouts fließen in die Belastung ein. Dicht und verlässlich." } }, "es": { @@ -149109,7 +149109,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Schritte, aktive Energie und erfasste Workouts fließen in deine Anstrengung ein. Dicht und verlässlich." + "value": "Schritte, aktive Energie und erfasste Workouts fließen in deine Belastung ein. Dicht und verlässlich." } }, "es": { @@ -150229,7 +150229,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anstrengungsziel" + "value": "Belastungsziel" } }, "es": { @@ -156627,7 +156627,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Deshalb beginnt eine Watch-Only-Ladung mit \"Kalibrieren\". NOOP benötigt etwa sieben Nächte Ihrer HRV, um zu erfahren, wie normal für Sie aussieht. Bis es sie hat, hält es die Punktzahl zurück, anstatt zu erraten. Sobald die Basislinie festgelegt ist, erscheint Ihre Ladung mit ihrem Vertrauen auf der gleichen 0-100-Skala wie ein Gurt." + "value": "Deshalb beginnt eine Watch-Only-Energie mit \"Kalibrieren\". NOOP benötigt etwa sieben Nächte Ihrer HRV, um zu erfahren, wie normal für Sie aussieht. Bis es sie hat, hält es die Punktzahl zurück, anstatt zu erraten. Sobald die Basislinie festgelegt ist, erscheint Ihre Energie mit ihrem Vertrauen auf der gleichen 0-100-Skala wie ein Gurt." } }, "es": { @@ -156661,7 +156661,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Deshalb startet eine Nur-Uhr-Ladung als „Kalibrierung“. NOOP braucht etwa sieben Nächte deiner HRV, um zu lernen, was für dich normal ist. Bis dahin hält es den Wert zurück, statt zu raten. Sobald die Basislinie steht, erscheint deine Ladung mit ihrer Konfidenz, auf derselben Skala von 0–100 wie bei einem Strap." + "value": "Deshalb startet eine Nur-Uhr-Energie als „Kalibrierung“. NOOP braucht etwa sieben Nächte deiner HRV, um zu lernen, was für dich normal ist. Bis dahin hält es den Wert zurück, statt zu raten. Sobald die Basislinie steht, erscheint deine Energie mit ihrer Konfidenz, auf derselben Skala von 0–100 wie bei einem Strap." } }, "es": { @@ -156869,7 +156869,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Der Durchschnitt oben wurde bearbeitet. Graph, Zonen und Anstrengung bleiben aus der aufgezeichneten Sitzung." + "value": "Der Durchschnitt oben wurde bearbeitet. Graph, Zonen und Belastung bleiben aus der aufgezeichneten Sitzung." } }, "es": { @@ -158916,7 +158916,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das baut sich aus dem Strap auf, während er synchronisiert. Anstrengung und Ruhe erscheinen, nachdem du ihn getragen und eine Nacht geschlafen hast. Die Ladung braucht etwa vier Nächte Schlaf, um deine Basislinie zu lernen (bis dahin siehst du „Kalibrierung“), und wird über deine ersten Wochen immer genauer. Auf einem WHOOP 5 oder MG speichert der Strap wenig Verlauf, der Nachtzähler kann also langsam steigen oder auf 0 von 4 stehen, bis du ihn ein paar Nächte getragen hast. Das ist seine Sync-Grenze, kein Fehler. Importiere deinen WHOOP-Export, um die Wartezeit zu überspringen." + "value": "Das baut sich aus dem Strap auf, während er synchronisiert. Belastung und Erholung erscheinen, nachdem du ihn getragen und eine Nacht geschlafen hast. Die Energie braucht etwa vier Nächte Schlaf, um deine Basislinie zu lernen (bis dahin siehst du „Kalibrierung“), und wird über deine ersten Wochen immer genauer. Auf einem WHOOP 5 oder MG speichert der Strap wenig Verlauf, der Nachtzähler kann also langsam steigen oder auf 0 von 4 stehen, bis du ihn ein paar Nächte getragen hast. Das ist seine Sync-Grenze, kein Fehler. Importiere deinen WHOOP-Export, um die Wartezeit zu überspringen." } }, "es": { @@ -160202,7 +160202,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das startet den etwa 4-nächtigen Aufbau für Ladung und deine HRV-Basislinie neu. Dein Verlauf bleibt." + "value": "Das startet den etwa 4-nächtigen Aufbau für Energie und deine HRV-Basislinie neu. Dein Verlauf bleibt." } }, "es": { @@ -160260,7 +160260,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Das startet den etwa 4-nächtigen Aufbau für Ladung und deine HRV-Basislinie neu. Dein Verlauf bleibt. Nutze es, wenn eine schlechte erste Woche, etwa krank getragen, deine Basislinie verzerrt hat." + "value": "Das startet den etwa 4-nächtigen Aufbau für Energie und deine HRV-Basislinie neu. Dein Verlauf bleibt. Nutze es, wenn eine schlechte erste Woche, etwa krank getragen, deine Basislinie verzerrt hat." } }, "es": { @@ -160376,7 +160376,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Der Beitrag dieser Sitzung zur Tages-Anstrengung, wie während des Workouts erfasst." + "value": "Der Beitrag dieser Sitzung zur Tages-Belastung, wie während des Workouts erfasst." } }, "es": { @@ -162586,7 +162586,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladung von morgen" + "value": "Energie von morgen" } }, "es": { @@ -162644,7 +162644,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Ladungs-Schätzung für morgen %1$lld plus/minus %2$lld" + "value": "Energie-Schätzung für morgen %1$lld plus/minus %2$lld" } }, "en": { @@ -163370,7 +163370,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Zeichne ein Workout manuell auf. Erfasst Herzfrequenz und Anstrengung, bis du es beendest." + "value": "Zeichne ein Workout manuell auf. Erfasst Herzfrequenz und Belastung, bis du es beendest." } }, "es": { @@ -172915,7 +172915,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Was deine Ladung geprägt hat" + "value": "Was deine Energie geprägt hat" } }, "es": { @@ -177245,7 +177245,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Du wachst voraussichtlich mit etwa %1$lld ± %2$lld Ladung auf, wenn du heute Nacht ungefähr %3$@ schläfst." + "value": "Du wachst voraussichtlich mit etwa %1$lld ± %2$lld Energie auf, wenn du heute Nacht ungefähr %3$@ schläfst." } }, "en": { @@ -177495,7 +177495,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Du bist verbunden. NOOP liest jetzt deine Apple-Watch-Daten. Dein Ladungswert kalibriert etwa in der ersten Woche, dann pendelt er sich ein." + "value": "Du bist verbunden. NOOP liest jetzt deine Apple-Watch-Daten. Dein Energiewert kalibriert etwa in der ersten Woche, dann pendelt er sich ein." } }, "es": { @@ -178023,7 +178023,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Du hast dein heutiges optimales Anstrengungsziel von %lld erreicht. Stark — deine Erholung hat es ermöglicht." + "value": "Du hast dein heutiges optimales Belastungsziel von %lld erreicht. Stark — deine Erholung hat es ermöglicht." } }, "es": { @@ -178115,7 +178115,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Deine Ladung (Erholung) auf dem Zifferblatt, mit Anstrengung und Ruhe in der rechteckigen Karte." + "value": "Deine Energie (Erholung) auf dem Zifferblatt, mit Belastung und Erholung in der rechteckigen Karte." } }, "es": { @@ -178173,7 +178173,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Dein Ladungswert lernt mit der Zeit eine persönliche Basislinie aus deiner Herzfrequenzvariabilität, Ruheherzfrequenz und mehr. Wenn eine schlechte erste Woche sie verzerrt hat, kannst du sie ab heute Nacht neu lernen lassen. Dein Verlauf bleibt." + "value": "Dein Energiewert lernt mit der Zeit eine persönliche Basislinie aus deiner Herzfrequenzvariabilität, Ruheherzfrequenz und weiteren Signalen. Wenn eine schlechte erste Woche sie verzerrt hat, kannst du sie ab heute Nacht neu lernen lassen. Dein Verlauf bleibt." } }, "es": { @@ -179409,7 +179409,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Deine Live-Herzfrequenz funktioniert bereits vom Strap, und Ladung, Anstrengung und Ruhe bauen sich daraus über die nächsten Nächte am Handgelenk auf und werden genauer, während er deine Basislinie lernt. Willst du deinen vollen Verlauf sofort? Importiere deinen WHOOP-Export unter „Datenquellen“, und er füllt sich in etwa einer Minute rückwirkend." + "value": "Deine Live-Herzfrequenz funktioniert bereits vom Strap, und Energie, Belastung und Erholung bauen sich daraus über die nächsten Nächte am Handgelenk auf und werden genauer, während er deine Basislinie lernt. Willst du deinen vollen Verlauf sofort? Importiere deinen WHOOP-Export unter „Datenquellen“, und er füllt sich in etwa einer Minute rückwirkend." } }, "es": { @@ -184267,7 +184267,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Anstrengung %@" + "value": "Belastung %@" } }, "es": { @@ -207396,7 +207396,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Benötigt %1$lld+ aufeinanderfolgende Tage mit Anstrengung, um zu starten. Bisher %2$lld." + "value": "Benötigt %1$lld+ aufeinanderfolgende Tage mit Belastung, um zu starten. Bisher %2$lld." } }, "es": { diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index a7da8b900a..0a33dca15c 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -265,6 +265,12 @@ def test_android_today_source_counts_use_two_plural_resources(self) -> None: self.assertIn("R.plurals.today_source_workouts", source) self.assertNotIn("R.string.today_source_counts", source) + def test_android_analytics_stays_free_of_ui_resources(self) -> None: + source = (ROOT / "android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt").read_text(encoding="utf-8") + self.assertNotIn("com.noop.R", source) + self.assertNotIn("androidx.annotation.StringRes", source) + self.assertIn("enum class Copy", source) + def test_android_english_home_contract_preserves_head_semantics(self) -> None: root = ET.parse(ROOT / "android/app/src/main/res/values/strings.xml").getroot() strings = {node.attrib["name"]: (node.text or "").replace("\\'", "'") for node in root.findall("string")} @@ -513,6 +519,24 @@ def test_apple_home_banner_and_suppression_contract_are_semantic(self) -> None: self.assertIn("public enum SuppressionReason", illness) self.assertIn("public let suppressionReasons: [SuppressionReason]", illness) + def test_apple_de_score_glossary_preserves_physiology_terms(self) -> None: + strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] + values = [ + entry.get("localizations", {}).get("de", {}).get("stringUnit", {}).get("value", "") + for entry in strings.values() + ] + joined = "\n".join(values) + self.assertNotIn("Erholungherz", joined) + self.assertNotIn("Erholungqualität", joined) + self.assertNotIn("Erholung- und Live-Herzfrequenz", joined) + for key, expected in { + "Charge": "Energie", + "Effort": "Belastung", + "Rest": "Erholung", + "How Rest is calculated": "So wird Erholung berechnet", + }.items(): + self.assertEqual(expected, strings[key]["localizations"]["de"]["stringUnit"]["value"]) + def test_apple_home_count_catalogs_have_real_focus_plural_variations(self) -> None: today = (ROOT / "Strand/Screens/TodayView.swift").read_text(encoding="utf-8") strings = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings")["strings"] diff --git a/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt b/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt index e4e83fdfe1..87acfc6806 100644 --- a/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/ReadinessEngine.kt @@ -1,7 +1,5 @@ package com.noop.analytics -import androidx.annotation.StringRes -import com.noop.R import com.noop.data.DailyMetric import kotlin.math.exp import kotlin.math.ln @@ -48,6 +46,24 @@ object ReadinessEngine { enum class MetricUnit { MS, BPM, RPM } + /** Semantic display-copy identifiers; Android resources are resolved only in the UI layer. */ + enum class Copy { + TODAY_READINESS_BALANCED, TODAY_READINESS_BALANCED_SUMMARY, + TODAY_READINESS_HRV_BAD, TODAY_READINESS_HRV_GOOD, TODAY_READINESS_HRV_WATCH, + TODAY_READINESS_LOAD_BUILDING_FAST, TODAY_READINESS_LOAD_RAMPING_DOWN, + TODAY_READINESS_LOAD_SPIKING, TODAY_READINESS_LOAD_SWEET_SPOT, + TODAY_READINESS_MONOTONY_WATCH, TODAY_READINESS_MORE_NIGHTS, + TODAY_READINESS_NORMAL_RANGE, TODAY_READINESS_PRIMED, TODAY_READINESS_PRIMED_SUMMARY, + TODAY_READINESS_RESP_BAD, TODAY_READINESS_RESP_WATCH, + TODAY_READINESS_RHR_BAD, TODAY_READINESS_RHR_GOOD, TODAY_READINESS_RHR_WATCH, + TODAY_READINESS_RUN_DOWN, TODAY_READINESS_RUN_DOWN_SUMMARY, + TODAY_READINESS_SIGNAL_HRV, TODAY_READINESS_SIGNAL_RESPIRATORY, + TODAY_READINESS_SIGNAL_RESTING_HR, TODAY_READINESS_SIGNAL_TRAINING_LOAD, + TODAY_READINESS_SIGNAL_TRAINING_VARIETY, TODAY_READINESS_STRAINED, + TODAY_READINESS_STRAINED_SUMMARY, TODAY_READINESS_TITLE, + TODAY_READINESS_WEAR_FOR_NIGHTS, + } + sealed interface Evidence { data class MetricVsBaseline( val value: Double, @@ -61,8 +77,8 @@ object ReadinessEngine { data class Signal( val key: String, // "hrv" | "rhr" | "respRate" | "acwr" | "monotony" - @StringRes val labelRes: Int, - @StringRes val detailRes: Int, + val labelRes: Copy, + val detailRes: Copy, val flag: Flag, // The numbers behind the signal, e.g. "48 vs 55 ms" or "7d 12.1 / 28d 9.4". Optional and // backward-compatible (defaults null); rendered as a small caption under the signal in the UI. @@ -71,8 +87,8 @@ object ReadinessEngine { data class Readiness( val level: Level, - @StringRes val headlineRes: Int, - @StringRes val summaryRes: Int, + val headlineRes: Copy, + val summaryRes: Copy, val signals: List, /** Acute:chronic workload ratio (null if not enough strain history). */ val acwr: Double?, @@ -174,8 +190,8 @@ object ReadinessEngine { if (latest == null) { return Readiness( level = Level.INSUFFICIENT, - headlineRes = R.string.today_readiness_title, - summaryRes = R.string.today_readiness_wear_for_nights, + headlineRes = Copy.TODAY_READINESS_TITLE, + summaryRes = Copy.TODAY_READINESS_WEAR_FOR_NIGHTS, signals = emptyList(), acwr = null, monotony = null, ) } @@ -187,15 +203,15 @@ object ReadinessEngine { val hrvSignal = zSignal( value = latest.avgHrv, baseline = history.takeLast(baselineWindow).mapNotNull { it.avgHrv }, - key = "hrv", labelRes = R.string.today_readiness_signal_hrv, + key = "hrv", labelRes = Copy.TODAY_READINESS_SIGNAL_HRV, unit = MetricUnit.MS, decimals = 0, higherIsBetter = true, cfg = Baselines.readinessHRVLnCfg, // RD2: ln-space spine, reject off logDomain = true, // RD1: lnRMSSD — HRV is right-skewed - goodTextRes = R.string.today_readiness_hrv_good, - neutralTextRes = R.string.today_readiness_normal_range, - watchTextRes = R.string.today_readiness_hrv_watch, - badTextRes = R.string.today_readiness_hrv_bad, + goodTextRes = Copy.TODAY_READINESS_HRV_GOOD, + neutralTextRes = Copy.TODAY_READINESS_NORMAL_RANGE, + watchTextRes = Copy.TODAY_READINESS_HRV_WATCH, + badTextRes = Copy.TODAY_READINESS_HRV_BAD, ) if (hrvSignal != null) signals.add(hrvSignal) @@ -203,14 +219,14 @@ object ReadinessEngine { val rhrSignal = zSignal( value = latest.restingHr?.toDouble(), baseline = history.takeLast(baselineWindow).mapNotNull { it.restingHr?.toDouble() }, - key = "rhr", labelRes = R.string.today_readiness_signal_resting_hr, + key = "rhr", labelRes = Copy.TODAY_READINESS_SIGNAL_RESTING_HR, unit = MetricUnit.BPM, decimals = 0, higherIsBetter = false, cfg = Baselines.restingHRCfg, // RD2: raw-bpm spine, reject off - goodTextRes = R.string.today_readiness_rhr_good, - neutralTextRes = R.string.today_readiness_normal_range, - watchTextRes = R.string.today_readiness_rhr_watch, - badTextRes = R.string.today_readiness_rhr_bad, + goodTextRes = Copy.TODAY_READINESS_RHR_GOOD, + neutralTextRes = Copy.TODAY_READINESS_NORMAL_RANGE, + watchTextRes = Copy.TODAY_READINESS_RHR_WATCH, + badTextRes = Copy.TODAY_READINESS_RHR_BAD, ) if (rhrSignal != null) signals.add(rhrSignal) @@ -231,16 +247,16 @@ object ReadinessEngine { if (z >= respZBad) { signals.add( Signal( - key = "respRate", labelRes = R.string.today_readiness_signal_respiratory, - detailRes = R.string.today_readiness_resp_bad, flag = Flag.BAD, + key = "respRate", labelRes = Copy.TODAY_READINESS_SIGNAL_RESPIRATORY, + detailRes = Copy.TODAY_READINESS_RESP_BAD, flag = Flag.BAD, evidence = respEvidence, ) ) } else if (z >= respZWatch) { signals.add( Signal( - key = "respRate", labelRes = R.string.today_readiness_signal_respiratory, - detailRes = R.string.today_readiness_resp_watch, flag = Flag.WATCH, + key = "respRate", labelRes = Copy.TODAY_READINESS_SIGNAL_RESPIRATORY, + detailRes = Copy.TODAY_READINESS_RESP_WATCH, flag = Flag.WATCH, evidence = respEvidence, ) ) @@ -270,8 +286,8 @@ object ReadinessEngine { if (mono >= 2.0) { signals.add( Signal( - key = "monotony", labelRes = R.string.today_readiness_signal_training_variety, - detailRes = R.string.today_readiness_monotony_watch, flag = Flag.WATCH, + key = "monotony", labelRes = Copy.TODAY_READINESS_SIGNAL_TRAINING_VARIETY, + detailRes = Copy.TODAY_READINESS_MONOTONY_WATCH, flag = Flag.WATCH, evidence = Evidence.Monotony(mono), ) ) @@ -304,11 +320,11 @@ object ReadinessEngine { /** Build a z-score signal for a metric where the baseline is the trailing window. */ private fun zSignal( value: Double?, baseline: List, - key: String, @StringRes labelRes: Int, unit: MetricUnit, decimals: Int, higherIsBetter: Boolean, + key: String, labelRes: Copy, unit: MetricUnit, decimals: Int, higherIsBetter: Boolean, cfg: MetricCfg, logDomain: Boolean = false, - @StringRes goodTextRes: Int, @StringRes neutralTextRes: Int, - @StringRes watchTextRes: Int, @StringRes badTextRes: Int, + goodTextRes: Copy, neutralTextRes: Copy, + watchTextRes: Copy, badTextRes: Copy, ): Signal? { if (value == null || baseline.size < minBaseline) return null // RD1: right-skewed metrics (HRV/RMSSD) are z-scored in the LOG domain — lnRMSSD is closer to @@ -335,7 +351,7 @@ object ReadinessEngine { // Orient z so positive always means "better". val z = (if (higherIsBetter) (tv - m) else (m - tv)) / sigma val flag: Flag - val detailRes: Int + val detailRes: Copy when { z >= 0.5 -> { flag = Flag.GOOD; detailRes = goodTextRes } z >= -0.5 -> { flag = Flag.NEUTRAL; detailRes = neutralTextRes } @@ -363,23 +379,23 @@ object ReadinessEngine { val evidence = Evidence.TrainingLoad(acute, chronic) return when { ratio < 0.8 -> Signal( - key = "acwr", labelRes = R.string.today_readiness_signal_training_load, - detailRes = R.string.today_readiness_load_ramping_down, flag = Flag.WATCH, + key = "acwr", labelRes = Copy.TODAY_READINESS_SIGNAL_TRAINING_LOAD, + detailRes = Copy.TODAY_READINESS_LOAD_RAMPING_DOWN, flag = Flag.WATCH, evidence = evidence, ) ratio < 1.3 -> Signal( - key = "acwr", labelRes = R.string.today_readiness_signal_training_load, - detailRes = R.string.today_readiness_load_sweet_spot, flag = Flag.GOOD, + key = "acwr", labelRes = Copy.TODAY_READINESS_SIGNAL_TRAINING_LOAD, + detailRes = Copy.TODAY_READINESS_LOAD_SWEET_SPOT, flag = Flag.GOOD, evidence = evidence, ) ratio < 1.5 -> Signal( - key = "acwr", labelRes = R.string.today_readiness_signal_training_load, - detailRes = R.string.today_readiness_load_building_fast, flag = Flag.WATCH, + key = "acwr", labelRes = Copy.TODAY_READINESS_SIGNAL_TRAINING_LOAD, + detailRes = Copy.TODAY_READINESS_LOAD_BUILDING_FAST, flag = Flag.WATCH, evidence = evidence, ) else -> Signal( - key = "acwr", labelRes = R.string.today_readiness_signal_training_load, - detailRes = R.string.today_readiness_load_spiking, flag = Flag.BAD, + key = "acwr", labelRes = Copy.TODAY_READINESS_SIGNAL_TRAINING_LOAD, + detailRes = Copy.TODAY_READINESS_LOAD_SPIKING, flag = Flag.BAD, evidence = evidence, ) } @@ -387,11 +403,11 @@ object ReadinessEngine { // MARK: Synthesis - private fun synthesize(signals: List, hasHistory: Boolean): Triple { + private fun synthesize(signals: List, hasHistory: Boolean): Triple { if (!hasHistory || signals.isEmpty()) { return Triple( - Level.INSUFFICIENT, R.string.today_readiness_title, - R.string.today_readiness_more_nights, + Level.INSUFFICIENT, Copy.TODAY_READINESS_TITLE, + Copy.TODAY_READINESS_MORE_NIGHTS, ) } val bad = signals.filter { it.flag == Flag.BAD } @@ -402,25 +418,25 @@ object ReadinessEngine { if (bad.size >= 2 || (recoveryDown && loadHigh)) { return Triple( - Level.RUNDOWN, R.string.today_readiness_run_down, - R.string.today_readiness_run_down_summary, + Level.RUNDOWN, Copy.TODAY_READINESS_RUN_DOWN, + Copy.TODAY_READINESS_RUN_DOWN_SUMMARY, ) } if (recoveryDown || loadHigh || bad.size >= 1) { return Triple( - Level.STRAINED, R.string.today_readiness_strained, - R.string.today_readiness_strained_summary, + Level.STRAINED, Copy.TODAY_READINESS_STRAINED, + Copy.TODAY_READINESS_STRAINED_SUMMARY, ) } if (good.size >= 2 && watch.isEmpty()) { return Triple( - Level.PRIMED, R.string.today_readiness_primed, - R.string.today_readiness_primed_summary, + Level.PRIMED, Copy.TODAY_READINESS_PRIMED, + Copy.TODAY_READINESS_PRIMED_SUMMARY, ) } return Triple( - Level.BALANCED, R.string.today_readiness_balanced, - R.string.today_readiness_balanced_summary, + Level.BALANCED, Copy.TODAY_READINESS_BALANCED, + Copy.TODAY_READINESS_BALANCED_SUMMARY, ) } diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index ce5e94d6e8..eff1a4e4be 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -2706,7 +2706,7 @@ private fun HeroRingColumn( .fillMaxWidth() .clip(RoundedCornerShape(50)) .clickable { onInfo() } - .padding(vertical = 2.dp), + .padding(vertical = Metrics.space2), contentAlignment = Alignment.Center, ) { // Keep the word centred on the vessel independently of the trailing chevron. Giving the label @@ -2718,7 +2718,7 @@ private fun HeroRingColumn( color = Palette.textSecondary, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 18.dp), + .padding(horizontal = Metrics.space18), minScale = 0.7f, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) @@ -2728,7 +2728,7 @@ private fun HeroRingColumn( tint = Palette.textSecondary.copy(alpha = 0.6f), modifier = Modifier .align(Alignment.CenterEnd) - .size(14.dp), + .size(Metrics.space14), ) } } @@ -6313,13 +6313,58 @@ private fun readinessEvidenceText(evidence: ReadinessEngine.Evidence): String { } } +@Composable +private fun readinessCopy(copy: ReadinessEngine.Copy): String = uiString( + when (copy) { + ReadinessEngine.Copy.TODAY_READINESS_BALANCED -> R.string.today_readiness_balanced + ReadinessEngine.Copy.TODAY_READINESS_BALANCED_SUMMARY -> R.string.today_readiness_balanced_summary + ReadinessEngine.Copy.TODAY_READINESS_HRV_BAD -> R.string.today_readiness_hrv_bad + ReadinessEngine.Copy.TODAY_READINESS_HRV_GOOD -> R.string.today_readiness_hrv_good + ReadinessEngine.Copy.TODAY_READINESS_HRV_WATCH -> R.string.today_readiness_hrv_watch + ReadinessEngine.Copy.TODAY_READINESS_LOAD_BUILDING_FAST -> R.string.today_readiness_load_building_fast + ReadinessEngine.Copy.TODAY_READINESS_LOAD_RAMPING_DOWN -> R.string.today_readiness_load_ramping_down + ReadinessEngine.Copy.TODAY_READINESS_LOAD_SPIKING -> R.string.today_readiness_load_spiking + ReadinessEngine.Copy.TODAY_READINESS_LOAD_SWEET_SPOT -> R.string.today_readiness_load_sweet_spot + ReadinessEngine.Copy.TODAY_READINESS_MONOTONY_WATCH -> R.string.today_readiness_monotony_watch + ReadinessEngine.Copy.TODAY_READINESS_MORE_NIGHTS -> R.string.today_readiness_more_nights + ReadinessEngine.Copy.TODAY_READINESS_NORMAL_RANGE -> R.string.today_readiness_normal_range + ReadinessEngine.Copy.TODAY_READINESS_PRIMED -> R.string.today_readiness_primed + ReadinessEngine.Copy.TODAY_READINESS_PRIMED_SUMMARY -> R.string.today_readiness_primed_summary + ReadinessEngine.Copy.TODAY_READINESS_RESP_BAD -> R.string.today_readiness_resp_bad + ReadinessEngine.Copy.TODAY_READINESS_RESP_WATCH -> R.string.today_readiness_resp_watch + ReadinessEngine.Copy.TODAY_READINESS_RHR_BAD -> R.string.today_readiness_rhr_bad + ReadinessEngine.Copy.TODAY_READINESS_RHR_GOOD -> R.string.today_readiness_rhr_good + ReadinessEngine.Copy.TODAY_READINESS_RHR_WATCH -> R.string.today_readiness_rhr_watch + ReadinessEngine.Copy.TODAY_READINESS_RUN_DOWN -> R.string.today_readiness_run_down + ReadinessEngine.Copy.TODAY_READINESS_RUN_DOWN_SUMMARY -> R.string.today_readiness_run_down_summary + ReadinessEngine.Copy.TODAY_READINESS_SIGNAL_HRV -> R.string.today_readiness_signal_hrv + ReadinessEngine.Copy.TODAY_READINESS_SIGNAL_RESPIRATORY -> R.string.today_readiness_signal_respiratory + ReadinessEngine.Copy.TODAY_READINESS_SIGNAL_RESTING_HR -> R.string.today_readiness_signal_resting_hr + ReadinessEngine.Copy.TODAY_READINESS_SIGNAL_TRAINING_LOAD -> R.string.today_readiness_signal_training_load + ReadinessEngine.Copy.TODAY_READINESS_SIGNAL_TRAINING_VARIETY -> R.string.today_readiness_signal_training_variety + ReadinessEngine.Copy.TODAY_READINESS_STRAINED -> R.string.today_readiness_strained + ReadinessEngine.Copy.TODAY_READINESS_STRAINED_SUMMARY -> R.string.today_readiness_strained_summary + ReadinessEngine.Copy.TODAY_READINESS_TITLE -> R.string.today_readiness_title + ReadinessEngine.Copy.TODAY_READINESS_WEAR_FOR_NIGHTS -> R.string.today_readiness_wear_for_nights + }, +) + @Composable private fun readinessSignalDetail(signal: ReadinessEngine.Signal): String { val load = signal.evidence as? ReadinessEngine.Evidence.TrainingLoad return if (load != null && load.chronic > 0.0) { - uiString(signal.detailRes, String.format(Locale.getDefault(), "%.2f", load.acute / load.chronic)) + uiString( + when (signal.detailRes) { + ReadinessEngine.Copy.TODAY_READINESS_LOAD_RAMPING_DOWN -> R.string.today_readiness_load_ramping_down + ReadinessEngine.Copy.TODAY_READINESS_LOAD_SWEET_SPOT -> R.string.today_readiness_load_sweet_spot + ReadinessEngine.Copy.TODAY_READINESS_LOAD_BUILDING_FAST -> R.string.today_readiness_load_building_fast + ReadinessEngine.Copy.TODAY_READINESS_LOAD_SPIKING -> R.string.today_readiness_load_spiking + else -> error("Training-load evidence requires a training-load detail") + }, + String.format(Locale.getDefault(), "%.2f", load.acute / load.chronic), + ) } else { - uiString(signal.detailRes) + readinessCopy(signal.detailRes) } } @@ -6352,7 +6397,7 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = ) Spacer(Modifier.width(10.dp)) Text( - uiString(readiness.headlineRes), + readinessCopy(readiness.headlineRes), style = NoopType.headline, color = Palette.textPrimary, modifier = Modifier.weight(1f), @@ -6377,7 +6422,7 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = // Plain-English summary. Text( - uiString(readiness.summaryRes), + readinessCopy(readiness.summaryRes), style = NoopType.subhead, color = Palette.textSecondary, ) @@ -6401,7 +6446,7 @@ private fun ReadinessSection(days: List, carriedDay: DailyMetric? = ) Spacer(Modifier.width(8.dp)) Text( - uiString(signal.labelRes), + readinessCopy(signal.labelRes), style = NoopType.caption, color = Palette.textSecondary, modifier = Modifier.width(104.dp), diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bfc9bf5496..6f4c5c316c 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2400,9 +2400,9 @@ %1$s, %2$s, %3$s, %4$s points, %5$s Vos scores quotidiens Les trois scores - NOOP propose trois scores quotidiens : Charge, Effort et Repos, de 0 à 100. Ils sont calculés sur votre appareil à partir des signaux du bracelet et de travaux publiés en sciences du sport. Ce sont des estimations indépendantes, pas les scores privés de WHOOP ; ils peuvent donc évoluer de façon similaire sans coïncider. - Charge : quel est votre niveau de récupération ? - La Charge dépend surtout de votre VFC comparée à votre référence personnelle, ainsi que de la fréquence cardiaque au repos, du Repos de la nuit dernière, de la respiration et de la température cutanée. Une VFC plus élevée donne davantage de Charge. NOOP a besoin de quelques nuits pour apprendre votre référence ; entre-temps, il affiche « Calibration en cours ». + NOOP propose trois scores quotidiens : Énergie, Effort et Repos, de 0 à 100. Ils sont calculés sur votre appareil à partir des signaux du bracelet et de travaux publiés en sciences du sport. Ce sont des estimations indépendantes, pas les scores privés de WHOOP ; ils peuvent donc évoluer de façon similaire sans coïncider. + Énergie : quel est votre niveau de récupération ? + L’Énergie dépend surtout de votre VFC comparée à votre référence personnelle, ainsi que de la fréquence cardiaque au repos, du Repos de la nuit dernière, de la respiration et de la température cutanée. Une VFC plus élevée donne davantage d’Énergie. NOOP a besoin de quelques nuits pour apprendre votre référence ; entre-temps, il affiche « Calibration en cours ». L’idée est proche du Recovery % de WHOOP, mais notre pondération et notre référence sont indépendantes et documentées. Effort : à quel point votre cœur a-t-il travaillé ? L’Effort mesure la charge cardiovasculaire. La fréquence cardiaque devient une impulsion d’entraînement selon les zones de réserve ; les zones intenses pèsent davantage. Le résultat suit une échelle logarithmique de 0 à 100. Les pas et l’énergie active comptent pour les activités longues avec peu de cardio. From 0be9db9668045ddce73709f4d7dda569ea056c58 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 12:27:42 +0200 Subject: [PATCH 07/12] Give localized score labels more room --- Tools/test_home_i18n.py | 2 ++ android/app/src/main/java/com/noop/ui/TodayScreen.kt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index 0a33dca15c..ffe9bc4146 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -252,6 +252,8 @@ def test_android_home_score_labels_and_explain_copy_are_resource_backed(self) -> def test_android_home_score_labels_and_empty_states_fit_localized_copy(self) -> None: source = (ROOT / "android/app/src/main/java/com/noop/ui/TodayScreen.kt").read_text(encoding="utf-8") self.assertIn("text = domainLabel.uppercase()", source) + self.assertIn(".padding(start = Metrics.space2, end = Metrics.space18)", source) + self.assertIn("minScale = 0.7f", source) self.assertIn("private fun RingNoData(diameter: Dp)", source) self.assertIn("maxLines = 2", source) self.assertIn("overflow = TextOverflow.Clip", source) diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index eff1a4e4be..60862b1f63 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -2718,7 +2718,9 @@ private fun HeroRingColumn( color = Palette.textSecondary, modifier = Modifier .fillMaxWidth() - .padding(horizontal = Metrics.space18), + // Keep almost the complete width available on the leading side. The larger trailing + // inset reserves space for the chevron without shifting or clipping longer labels. + .padding(start = Metrics.space2, end = Metrics.space18), minScale = 0.7f, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) From 18e25e391094293aa29a0df9e5ae1026304fe75c Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 14:12:55 +0200 Subject: [PATCH 08/12] Keep localized section titles on one line --- Tools/test_home_i18n.py | 7 +++++++ .../src/main/java/com/noop/ui/Components.kt | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index ffe9bc4146..d83a4f539b 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -260,6 +260,13 @@ def test_android_home_score_labels_and_empty_states_fit_localized_copy(self) -> self.assertNotIn("Text(domainLabel.uppercase()", source) self.assertNotIn("private fun RingNoData()", source) + def test_android_section_header_trailing_copy_does_not_squeeze_title(self) -> None: + source = (ROOT / "android/app/src/main/java/com/noop/ui/Components.kt").read_text(encoding="utf-8") + section_header = source.split("fun SectionHeader(", 1)[1].split("// MARK: - StrandTone", 1)[0] + self.assertIn("if (overline != null || trailing != null)", section_header) + self.assertIn("Text(title, style = NoopType.title2", section_header) + self.assertLess(section_header.index("if (trailing != null)"), section_header.index("Text(title")) + def test_android_today_source_counts_use_two_plural_resources(self) -> None: relative = "android/app/src/main/java/com/noop/ui/TodayScreen.kt" source = audit._mask_comments((ROOT / relative).read_text(encoding="utf-8")) diff --git a/android/app/src/main/java/com/noop/ui/Components.kt b/android/app/src/main/java/com/noop/ui/Components.kt index f6adc556bc..a32227395e 100644 --- a/android/app/src/main/java/com/noop/ui/Components.kt +++ b/android/app/src/main/java/com/noop/ui/Components.kt @@ -267,17 +267,21 @@ fun SectionHeader( trailing: String? = null, modifier: Modifier = Modifier, ) { - Row( + Column( modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, ) { - Column(modifier = Modifier.weight(1f)) { - if (overline != null) Overline(overline) - Text(title, style = NoopType.title2, color = Palette.textPrimary) - } - if (trailing != null) { - Text(trailing, style = NoopType.footnote, color = Palette.textSecondary) + if (overline != null || trailing != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + if (overline != null) Overline(overline, modifier = Modifier.weight(1f)) + if (trailing != null) { + Text(trailing, style = NoopType.footnote, color = Palette.textSecondary) + } + } } + Text(title, style = NoopType.title2, color = Palette.textPrimary) } } From c01a1c3e611b2a2d385c6a1bc7781e6833db072c Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Thu, 20 Aug 2026 14:34:26 +0200 Subject: [PATCH 09/12] Fix DayNavBar date label compilation --- Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift | 5 +++-- Tools/test_home_i18n.py | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift b/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift index 1c64257531..6a1694bacb 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift @@ -42,9 +42,10 @@ public struct DayNavBar: View { case 0: return "Today" case 1: return "Yesterday" default: - return "\(selectedDay.formatted( + let formattedDay = selectedDay.formatted( .dateTime.weekday(.abbreviated).day().month(.abbreviated).locale(locale) - ))" + ) + return LocalizedStringKey(formattedDay) } } diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index d83a4f539b..3d647c1ce6 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -400,6 +400,12 @@ def test_apple_home_has_no_audit_findings(self) -> None: scoped = [row for row in findings if row[0] in APPLE_HOME_FILES] self.assertEqual([], scoped, "Unlocalized Apple Home UI:\n" + _format_findings(scoped)) + def test_apple_day_nav_dynamic_date_avoids_multiline_interpolation(self) -> None: + source = (ROOT / "Packages/StrandDesign/Sources/StrandDesign/DayNavBar.swift").read_text(encoding="utf-8") + self.assertIn("let formattedDay = selectedDay.formatted(", source) + self.assertIn("return LocalizedStringKey(formattedDay)", source) + self.assertNotIn('return "\\(selectedDay.formatted(', source) + def test_apple_home_catalog_entries_cover_focus_locales(self) -> None: missing: list[str] = [] catalogs: dict[Path, dict] = {} From 3ce88ad00133d4ef064b0c0897f225e770fb3607 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 21 Aug 2026 14:17:54 +0200 Subject: [PATCH 10/12] Cover every Charge driver localization key --- .../StrandAnalytics/ChargeDrivers.swift | 4 ++ Strand/Resources/Localizable.xcstrings | 58 +++++++++++++++++++ Tools/test_home_i18n.py | 24 ++++++++ 3 files changed, 86 insertions(+) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift index cf46e35d24..381056fd0e 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift @@ -242,6 +242,10 @@ extension RecoveryScorer { // MARK: - Plain-English verdicts (no fabricated numbers; direction only) + // Every returned literal is also a runtime localization key consumed by ChargeBreakdownFormat. + // When adding or rewording a verdict, add the identical key to Strand's Localizable.xcstrings; + // Tools/test_home_i18n.py enforces that complete engine-to-catalog contract. + static func hrvVerdict(value: Double, baseline: Double, saturationDetected: Bool = false) -> String { if value > baseline { return "above baseline, supporting recovery" } if value < baseline { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 93b4946ac7..94103719a9 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -182958,6 +182958,64 @@ } } }, + "below baseline, limiting recovery, though low resting HR suggests this may be parasympathetic saturation rather than fatigue": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "unter der Basislinie, bremst die Erholung; die niedrige Ruheherzfrequenz deutet jedoch darauf hin, dass es sich eher um parasympathische Sättigung als um Ermüdung handeln könnte" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "por debajo de tu línea base, limita la recuperación, aunque la baja frecuencia cardíaca en reposo sugiere que podría tratarse de saturación parasimpática en lugar de fatiga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "en dessous de la référence, limite la récupération, bien qu’une faible fréquence cardiaque au repos suggère qu’il pourrait s’agir d’une saturation parasympathique plutôt que de fatigue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "sotto la linea di base, limita il recupero, anche se la bassa frequenza cardiaca a riposo suggerisce che potrebbe trattarsi di saturazione parasimpatica anziché affaticamento" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "abaixo do valor de referência, limita a recuperação, embora a frequência cardíaca baixa em repouso sugira que possa tratar-se de saturação parassimpática em vez de fadiga" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ниже нормы, ограничивает восстановление, однако низкий пульс в покое указывает, что причиной может быть парасимпатическое насыщение, а не усталость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低于基线,限制恢复,但较低的静息心率表明这可能是副交感饱和,而非疲劳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低於基線,限制恢復,但較低的靜息心率顯示這可能是副交感飽和,而非疲勞" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "poniżej wartości wyjściowej, ogranicza regenerację, choć niskie tętno spoczynkowe sugeruje, że może to być nasycenie przywspółczulne, a nie zmęczenie" + } + } + } + }, "below baseline, supporting recovery": { "localizations": { "de": { diff --git a/Tools/test_home_i18n.py b/Tools/test_home_i18n.py index 3d647c1ce6..ce2da56f11 100644 --- a/Tools/test_home_i18n.py +++ b/Tools/test_home_i18n.py @@ -406,6 +406,30 @@ def test_apple_day_nav_dynamic_date_avoids_multiline_interpolation(self) -> None self.assertIn("return LocalizedStringKey(formattedDay)", source) self.assertNotIn('return "\\(selectedDay.formatted(', source) + def test_apple_charge_driver_verdicts_are_complete_catalog_keys(self) -> None: + source = (ROOT / "Packages/StrandAnalytics/Sources/StrandAnalytics/ChargeDrivers.swift").read_text( + encoding="utf-8" + ) + verdict_block = source.split("// MARK: - Plain-English verdicts", 1)[1].split( + "static func skinTempDevText", 1 + )[0] + verdicts = set(re.findall(r'(?:return|\?|:)\s*"([^"]+)"', verdict_block)) + # Thirteen return paths currently collapse to twelve unique keys because several helpers share + # "at baseline". Pin the unique-key set size so syntax changes cannot silently evade extraction. + self.assertEqual(12, len(verdicts), "Verdict extraction changed; review the catalog contract") + + catalog = audit.load_catalog(ROOT / "Strand/Resources/Localizable.xcstrings") + missing = [] + for verdict in sorted(verdicts): + entry = audit.swift_catalog_lookup(catalog, verdict) + if entry is None: + missing.append(f"all: {verdict!r} absent from catalog") + continue + for lang in audit.LANGS: + if not audit._is_translated(entry, lang): + missing.append(f"{lang}: {verdict!r}") + self.assertEqual([], missing, "Missing Charge-driver verdict translations:\n" + "\n".join(missing)) + def test_apple_home_catalog_entries_cover_focus_locales(self) -> None: missing: list[str] = [] catalogs: dict[Path, dict] = {} From 2de82bc5f42f2cb766101349078645ce98f3adbe Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Fri, 21 Aug 2026 14:30:31 +0200 Subject: [PATCH 11/12] Reconcile Home localization with current main --- .../src/main/java/com/noop/ui/TodayScreen.kt | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 60862b1f63..4ae595cf74 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -1545,23 +1545,9 @@ fun TodayScreen( caloriesSpark = caloriesSpark, // #616: imported-first trend stepActivityClassForDay = stepActivityClassForDay, stepsEstimateCaption = stepsEstimateCaption(profileStore), - // Remembered on the calibration state it reads, matching the same - // preference lookup in SettingsScreen: this is a SharedPreferences - // read and the Today body recomposes with live HR. - // - // The strap model is a key too, because the prompt returns null for - // anything but a 4.0 -- and that key is rewritten under a composed - // Today, by Settings and by live detection in WhoopBleClient. Keyed - // only on the calibration values, a strap switch left the previous - // answer on screen until one of those happened to move. Re-reading - // the key per pass is a loaded-map lookup; what stays memoised is - // the rest of the prompt. - stepsCalibrationPrompt = remember( - profileStore.stepsCalibrationSampleDays, - profileStore.stepsCalibrationCoefficient, - profileStore.stepsCalibrationManual, - NoopPrefs.of(context).getString("noop.selectedWhoopModel", null), - ) { stepsCalibrationPrompt(context, profileStore) }, + // Resolve the semantic engine headline at the composable UI boundary. + // This preference read is cheap and only feeds the otherwise-empty tile. + stepsCalibrationPrompt = stepsCalibrationPrompt(context, profileStore), restScore = restScoreForDay, restSpark = restCompositeSpark, enabledMetrics = enabledKeyMetrics, @@ -3690,6 +3676,7 @@ private fun DashboardCardRow( * The wording is the engine's own [StepsEstimateEngine.CalibrationStatus.headline], which already says * "connect your phone's step count" when NO day has both motion and a phone total, rather than a * countdown that would never advance. */ +@Composable private fun stepsCalibrationPrompt(context: Context, profileStore: ProfileStore): String? { val model = context.getSharedPreferences(NoopPrefs.NAME, Context.MODE_PRIVATE) .getString("noop.selectedWhoopModel", null) ?: return null @@ -3709,10 +3696,19 @@ private fun stepsCalibrationPrompt(context: Context, profileStore: ProfileStore) ) { return null } - return StepsEstimateEngine.CalibrationStatus.NeedsMoreDays( + val headline = StepsEstimateEngine.CalibrationStatus.NeedsMoreDays( have = profileStore.stepsCalibrationSampleDays, need = StepsEstimateEngine.MIN_CALIBRATION_DAYS, ).headline + return when (headline) { + StepsEstimateEngine.CalibrationStatus.Headline.Manual -> uiString(R.string.today_steps_headline_manual) + is StepsEstimateEngine.CalibrationStatus.Headline.Calibrated -> + uiString(R.string.today_steps_headline_calibrated, headline.sampleDays) + StepsEstimateEngine.CalibrationStatus.Headline.ConnectPhoneSteps -> + uiString(R.string.today_steps_headline_connect_phone) + is StepsEstimateEngine.CalibrationStatus.Headline.NeedMoreDays -> + uiString(R.string.today_steps_headline_more_days, headline.remaining) + } } /** #760/#792: the caption under an ESTIMATED Steps tile: "est. · ", where the detail is the From c11a46fd774e2f9b28a8d6ab99abe000d07dd40d Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 22 Aug 2026 09:02:06 +1200 Subject: [PATCH 12/12] test: move the Swift provenance expectations onto the WHOOP brand spelling This PR renames the provenance brand label "Whoop" -> "WHOOP" (whoopBrandName in TodayView, today_source_whoop on Android) and updates the Kotlin twin, but the Swift twin still asserted the old spelling, so StrandTests failed 6 of 1188. Nothing in this PR's checks could show it: StrandTests runs only in app-build, which is disabled by default, so the five app-target Swift files here are neither compiled nor tested by any check that ran. Moves the expectations rather than the rename -- the stylised all-caps matches how the brand is written everywhere else in the project. TodayExplainabilityTests: provenanceDisplayLabel x2, todayProvenanceChipLabel, todayScoreProviderLabel, and the "WHOOP + Oura" hero composite VitalReadingsTableTests: the per-sample source row Doc lines naming the literal label moved with them. Verified: app-build on this branch merged onto main -- see the PR comment. --- StrandTests/TodayExplainabilityTests.swift | 12 ++++++------ StrandTests/VitalReadingsTableTests.swift | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/StrandTests/TodayExplainabilityTests.swift b/StrandTests/TodayExplainabilityTests.swift index 44f131b1f0..ecef4bf03d 100644 --- a/StrandTests/TodayExplainabilityTests.swift +++ b/StrandTests/TodayExplainabilityTests.swift @@ -34,7 +34,7 @@ private func rendered(_ key: LocalizedStringKey?) -> String? { /// rules can't silently regress and stay byte-for-byte in step with the Kotlin Today lane: /// • Component 2 — explained score states (calibrating / carriedLastNight / needsStrap) /// • Component 3 — recording status (recording / lastSynced Xm ago / notRecording) -/// • Component 4 — provenance label (On-device / Whoop / Apple Health = the real per-day merge winner) +/// • Component 4 — provenance label (On-device / WHOOP / Apple Health = the real per-day merge winner) final class TodayExplainabilityTests: XCTestCase { // MARK: - Component 2 — MetricTileState.resolve precedence @@ -281,7 +281,7 @@ final class TodayExplainabilityTests: XCTestCase { func testProvenance_importedStrapSource_isWhoop() { XCTAssertEqual(TodayView.provenanceDisplayLabel(rawSource: "my-whoop", deviceId: "my-whoop"), - "Whoop") + "WHOOP") } func testProvenance_appleHealthSource_isAppleHealth() { @@ -294,7 +294,7 @@ final class TodayExplainabilityTests: XCTestCase { XCTAssertEqual(TodayView.provenanceDisplayLabel(rawSource: "whoop5-AB12-noop", deviceId: "whoop5-AB12"), "On-device") XCTAssertEqual(TodayView.provenanceDisplayLabel(rawSource: "whoop5-AB12", deviceId: "whoop5-AB12"), - "Whoop") + "WHOOP") } func testProvenance_crossStrapComputedSibling_stillOnDevice() { @@ -339,7 +339,7 @@ final class TodayExplainabilityTests: XCTestCase { XCTAssertEqual( TodayView.todayProvenanceChipLabel(rawSource: "my-whoop", deviceId: "my-whoop", appleHealthSource: "apple-health"), - "Whoop") + "WHOOP") XCTAssertEqual( TodayView.todayProvenanceChipLabel(rawSource: "my-whoop-noop", deviceId: "my-whoop", appleHealthSource: "apple-health"), @@ -351,7 +351,7 @@ final class TodayExplainabilityTests: XCTestCase { } func testTodayScoreProviderLabel_coversImportedAndRegisteredProviders() { - XCTAssertEqual(TodayView.todayScoreProviderLabel(sourceId: "my-whoop", brand: "WHOOP"), "Whoop") + XCTAssertEqual(TodayView.todayScoreProviderLabel(sourceId: "my-whoop", brand: "WHOOP"), "WHOOP") XCTAssertEqual(TodayView.todayScoreProviderLabel(sourceId: "apple-health", brand: nil), "Apple Watch") XCTAssertEqual(TodayView.todayScoreProviderLabel(sourceId: "health-connect", brand: nil), "Health Connect") XCTAssertEqual(TodayView.todayScoreProviderLabel(sourceId: "oura-import", brand: nil), "Oura") @@ -387,7 +387,7 @@ final class TodayExplainabilityTests: XCTestCase { .init(sourceId: "oura-import", brand: nil), .init(sourceId: "apple-health", brand: nil), ]), - "Whoop + Oura") + "WHOOP + Oura") } func testLiquidHeroSourceLabel_hidesWhenNoScoreHasAResolvedSource() { diff --git a/StrandTests/VitalReadingsTableTests.swift b/StrandTests/VitalReadingsTableTests.swift index 9e15d6bf0e..18d88644a1 100644 --- a/StrandTests/VitalReadingsTableTests.swift +++ b/StrandTests/VitalReadingsTableTests.swift @@ -5,7 +5,7 @@ import XCTest /// `VitalReadingsTableTest`. Pins the pure projection `vitalReadingRows` the MetricDetailView table /// renders: rows and the "N readings" caption derive from the SAME windowed list (so their counts can't /// disagree), rows are NEWEST-FIRST, each raw source id resolves through the shared -/// `TodayView.provenanceDisplayLabel` (strap → "Whoop", Health Connect → "Health Connect", Apple Health → +/// `TodayView.provenanceDisplayLabel` (strap → "WHOOP", Health Connect → "Health Connect", Apple Health → /// "Apple Health", the "-noop" sibling → "On-device"), and each value reuses the model's own formatter + /// unit. Blood Oxygen (SpO2) is the acceptance case. final class VitalReadingsTableTests: XCTestCase { @@ -46,7 +46,7 @@ final class VitalReadingsTableTests: XCTestCase { let rows = vitalReadingRows(readings: spo2Readings(), unit: "%", strapDeviceId: strap, now: now, format: spo2Format) // Newest-first, so: Apple Health (03), Health Connect (02), Whoop strap (01). - XCTAssertEqual(rows.map(\.source), ["Apple Health", "Health Connect", "Whoop"]) + XCTAssertEqual(rows.map(\.source), ["Apple Health", "Health Connect", "WHOOP"]) } func testComputedStrapSiblingReadsOnDevice() {