diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PersonalRecords.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PersonalRecords.swift new file mode 100644 index 0000000000..051fdea7c3 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PersonalRecords.swift @@ -0,0 +1,147 @@ +import Foundation + +// PersonalRecords.swift - all-time personal bests over the user's own daily history. +// +// The WHOOP app has a "personal records" surface, but its records are computed server-side and limited +// to weightlifting exercises (observed in the shipped app's model shapes; facts only, no WHOOP code). +// NOOP has no server, so this is the local equivalent over the daily metrics NOOP already stores: the +// best HRV, the lowest resting HR, the best Charge, the highest strain, the longest sleep and so on. +// +// Rules (Kotlin twin: com.noop.analytics.PersonalRecords, held aligned by mirrored fixtures): +// - each metric has a plausibility range; values outside it never become a record; +// - higher is better for every metric except resting HR, where lower is better; +// - a tie does not beat a record: the EARLIEST day holding the best value keeps it; +// - a record is "new" when it was set within the last `newWindowDays` days ending at today AND at +// least `minPriorValues` usable values came before it. +// +// Civil-day arithmetic is TZ-free (`Baselines.isoEpochDay`). Pure: no clock, no I/O, no storage. +// PRESENTATION ONLY: nothing here feeds a score, a baseline or a stored value. No em-dashes. + +/// One tracked metric. The raw value matches the Kotlin `PersonalRecords.Metric.raw`. +public enum PersonalRecordMetric: String, CaseIterable, Sendable { + case hrv, restingHr, recovery, strain, sleep, efficiency, deepSleep, remSleep, steps + + /// True only for resting HR. + public var lowerIsBetter: Bool { self == .restingHr } + + /// Inclusive plausibility range; values outside it are ignored. + public var range: ClosedRange { + switch self { + case .hrv: return 5...300 + case .restingHr: return 25...120 + case .recovery: return 0...100 + case .strain: return 0...21 + case .sleep: return 60...960 + case .efficiency: return 0.5...1.0 + case .deepSleep, .remSleep: return 1...360 + case .steps: return 1...100_000 + } + } +} + +public enum PersonalRecords { + + /// Days (inclusive of today) a record counts as new. + public static let newWindowDays = 7 + + /// Usable values that must precede a record before it can be called new. + public static let minPriorValues = 14 + + /// One day of input. Efficiency may be a fraction (0.91) or a percent (91); it is read as a fraction. + public struct Day: Equatable, Sendable { + public var day: String + public var hrvMs: Double? + public var restingHr: Double? + public var recovery: Double? + public var strain: Double? + public var sleepMin: Double? + public var efficiency: Double? + public var deepMin: Double? + public var remMin: Double? + public var steps: Double? + + public init(day: String, hrvMs: Double? = nil, restingHr: Double? = nil, recovery: Double? = nil, + strain: Double? = nil, sleepMin: Double? = nil, efficiency: Double? = nil, + deepMin: Double? = nil, remMin: Double? = nil, steps: Double? = nil) { + self.day = day + self.hrvMs = hrvMs + self.restingHr = restingHr + self.recovery = recovery + self.strain = strain + self.sleepMin = sleepMin + self.efficiency = efficiency + self.deepMin = deepMin + self.remMin = remMin + self.steps = steps + } + } + + /// The best value of one metric, the day it was set and the best that stood before it. + public struct Record: Equatable, Sendable { + public let metric: PersonalRecordMetric + public let value: Double + public let day: String + public let previousBest: Double? + public let isNew: Bool + /// Usable values the record was chosen from. + public let samples: Int + } + + /// Read `metric` off `d`, normalising efficiency to a fraction. + /// Kotlin twin: `PersonalRecords.value`. + public static func value(_ d: Day, _ metric: PersonalRecordMetric) -> Double? { + switch metric { + case .hrv: return d.hrvMs + case .restingHr: return d.restingHr + case .recovery: return d.recovery + case .strain: return d.strain + case .sleep: return d.sleepMin + case .efficiency: return d.efficiency.map { $0 > 1.0 ? $0 / 100.0 : $0 } + case .deepSleep: return d.deepMin + case .remSleep: return d.remMin + case .steps: return d.steps + } + } + + /// One `Record` per metric that has at least one usable value, in `allCases` order. Rows with an + /// unparseable day key are ignored; rows may arrive in any order. When `today` does not parse, + /// nothing is marked new. + /// Kotlin twin: `PersonalRecords.records`. + public static func records(_ days: [Day], today: String) -> [Record] { + // Chronological by civil day; stable, so same-day duplicates keep their input order. + let dated = days.enumerated() + .compactMap { i, d in Baselines.isoEpochDay(d.day).map { (epoch: $0, index: i, day: d) } } + .sorted { $0.epoch != $1.epoch ? $0.epoch < $1.epoch : $0.index < $1.index } + let todayEpoch = Baselines.isoEpochDay(today) + var out: [Record] = [] + for metric in PersonalRecordMetric.allCases { + var best: Double? + var bestDay = "" + var bestEpoch = 0 + var previous: Double? + var priorCount = 0 + var samples = 0 + for row in dated { + guard let v = value(row.day, metric), !v.isNaN, metric.range.contains(v) else { continue } + let beats: Bool + if let cur = best { beats = metric.lowerIsBetter ? v < cur : v > cur } else { beats = true } + if beats { + previous = best + best = v + bestDay = row.day.day + bestEpoch = row.epoch + priorCount = samples + } + samples += 1 + } + guard let b = best else { continue } + var isNew = false + if let t = todayEpoch { + isNew = bestEpoch <= t && bestEpoch > t - newWindowDays && priorCount >= minPriorValues + } + out.append(Record(metric: metric, value: b, day: bestDay, previousBest: previous, + isNew: isNew, samples: samples)) + } + return out + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PersonalRecordsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PersonalRecordsTests.swift new file mode 100644 index 0000000000..30d9c5461a --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PersonalRecordsTests.swift @@ -0,0 +1,118 @@ +import XCTest +@testable import StrandAnalytics + +/// Mirrors com.noop.analytics.PersonalRecordsTest - same fixtures, same expected outputs. +final class PersonalRecordsTests: XCTestCase { + + private let today = "2026-10-20" + + private func key(_ epoch: Int) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(epoch) * 86_400) + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + let c = cal.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) + } + + /// `n` consecutive days ending at `end`, HRV from `hrv` by index. + private func daysRun(_ n: Int, end: String, hrv: (Int) -> Double?) -> [PersonalRecords.Day] { + let endEpoch = Baselines.isoEpochDay(end)! + return (0.. PersonalRecords.Record? { + PersonalRecords.records(days, today: today ?? self.today).first { $0.metric == metric } + } + + func testEmptyHistoryHasNoRecords() { + XCTAssertEqual(PersonalRecords.records([], today: today), []) + } + + func testHighestHrvWinsAndKeepsPreviousBest() { + let r = record(daysRun(5, end: "2026-10-01") { [40.0, 55, 50, 70, 60][$0] }, .hrv)! + XCTAssertEqual(r.value, 70) + XCTAssertEqual(r.day, "2026-09-30") + XCTAssertEqual(r.previousBest, 55) + XCTAssertEqual(r.samples, 5) + XCTAssertFalse(r.isNew) + } + + func testRestingHrLowerIsBetter() { + let days = [ + PersonalRecords.Day(day: "2026-10-01", restingHr: 52), + PersonalRecords.Day(day: "2026-10-02", restingHr: 47), + PersonalRecords.Day(day: "2026-10-03", restingHr: 49), + ] + let r = record(days, .restingHr)! + XCTAssertEqual(r.value, 47) + XCTAssertEqual(r.day, "2026-10-02") + XCTAssertEqual(r.previousBest, 52) + } + + func testTieKeepsEarliestDay() { + let r = record(daysRun(3, end: "2026-10-01") { _ in 60 }, .hrv)! + XCTAssertEqual(r.day, "2026-09-29") + XCTAssertNil(r.previousBest) + } + + func testImplausibleValuesAreIgnored() { + let days = [ + PersonalRecords.Day(day: "2026-10-01", hrvMs: 400, restingHr: 10, sleepMin: 12), + PersonalRecords.Day(day: "2026-10-02", hrvMs: 65, restingHr: 50, sleepMin: 480), + ] + XCTAssertEqual(record(days, .hrv)?.value, 65) + XCTAssertEqual(record(days, .sleep)?.value, 480) + XCTAssertEqual(record(days, .restingHr)?.value, 50) + XCTAssertEqual(record(days, .hrv)?.samples, 1) + } + + func testEfficiencyPercentReadsAsFraction() { + let days = [PersonalRecords.Day(day: "2026-10-01", efficiency: 91), + PersonalRecords.Day(day: "2026-10-02", efficiency: 0.88)] + XCTAssertEqual(record(days, .efficiency)!.value, 0.91, accuracy: 1e-12) + } + + func testUnorderedInputAndBadKeys() { + let days = [ + PersonalRecords.Day(day: "2026-10-03", hrvMs: 50), + PersonalRecords.Day(day: "not-a-day", hrvMs: 99), + PersonalRecords.Day(day: "2026-10-01", hrvMs: 50), + PersonalRecords.Day(day: "2026-10-02", hrvMs: 45), + ] + let r = record(days, .hrv)! + XCTAssertEqual(r.day, "2026-10-01") + XCTAssertEqual(r.samples, 3) + } + + func testNewRecordNeedsRecentDayAndEnoughHistory() { + let r = record(daysRun(20, end: today) { $0 == 19 ? 90 : 50 + Double($0) }, .hrv)! + XCTAssertEqual(r.day, today) + XCTAssertTrue(r.isNew) + XCTAssertEqual(r.previousBest, 68) + } + + func testRecordInFirstWeeksIsNotNew() { + let r = record(daysRun(10, end: today) { 50 + Double($0) }, .hrv)! + XCTAssertEqual(r.day, today) + XCTAssertFalse(r.isNew) + } + + func testNewWindowIsSevenDaysInclusive() { + XCTAssertTrue(record(daysRun(20, end: "2026-10-14") { $0 == 19 ? 90 : 50 }, .hrv)!.isNew) + XCTAssertFalse(record(daysRun(20, end: "2026-10-13") { $0 == 19 ? 90 : 50 }, .hrv)!.isNew) + } + + func testFutureDayIsNeverNew() { + XCTAssertFalse(record(daysRun(20, end: "2026-10-22") { $0 == 19 ? 90 : 50 }, .hrv)!.isNew) + } + + func testUnparseableTodayMarksNothingNew() { + XCTAssertFalse(record(daysRun(20, end: today) { $0 == 19 ? 90 : 50 }, .hrv, today: "bad")!.isNew) + } + + func testMetricsWithoutValuesAreOmittedAndOrderIsStable() { + let days = [PersonalRecords.Day(day: "2026-10-01", hrvMs: 61, strain: 14.2, steps: 12_000)] + XCTAssertEqual(PersonalRecords.records(days, today: today).map(\.metric), [.hrv, .strain, .steps]) + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..a75547ee50 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,30 @@ { "sourceLanguage": "en", "strings": { + "All time": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Gesamt"}}, "es": {"stringUnit": {"state": "translated", "value": "Desde siempre"}}, "fr": {"stringUnit": {"state": "translated", "value": "Depuis toujours"}}, "it": {"stringUnit": {"state": "translated", "value": "Da sempre"}}, "pl": {"stringUnit": {"state": "translated", "value": "Od początku"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Desde sempre"}}, "ru": {"stringUnit": {"state": "translated", "value": "За всё время"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "全部时间"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "全部時間"}} + } }, + "Personal bests": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Persönliche Bestwerte"}}, "es": {"stringUnit": {"state": "translated", "value": "Mejores marcas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Records personnels"}}, "it": {"stringUnit": {"state": "translated", "value": "Record personali"}}, "pl": {"stringUnit": {"state": "translated", "value": "Rekordy osobiste"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Recordes pessoais"}}, "ru": {"stringUnit": {"state": "translated", "value": "Личные рекорды"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "个人最佳"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "個人最佳"}} + } }, + "Records appear once NOOP has scored a few days.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Bestwerte erscheinen, sobald NOOP einige Tage bewertet hat."}}, "es": {"stringUnit": {"state": "translated", "value": "Las marcas aparecen cuando NOOP haya puntuado algunos días."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les records apparaissent quand NOOP a noté quelques jours."}}, "it": {"stringUnit": {"state": "translated", "value": "I record compaiono quando NOOP ha valutato alcuni giorni."}}, "pl": {"stringUnit": {"state": "translated", "value": "Rekordy pojawią się, gdy NOOP oceni kilka dni."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os recordes aparecem quando o NOOP tiver pontuado alguns dias."}}, "ru": {"stringUnit": {"state": "translated", "value": "Рекорды появятся, когда NOOP оценит несколько дней."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "NOOP 评分几天后会显示记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "NOOP 評分幾天後會顯示記錄。"}} + } }, + "From your own history on this device. Implausible readings never count as a record.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aus deinem eigenen Verlauf auf diesem Gerät. Unplausible Werte zählen nie als Bestwert."}}, "es": {"stringUnit": {"state": "translated", "value": "De tu propio historial en este dispositivo. Las lecturas inverosímiles nunca cuentan como marca."}}, "fr": {"stringUnit": {"state": "translated", "value": "À partir de votre propre historique sur cet appareil. Les mesures invraisemblables ne comptent jamais comme record."}}, "it": {"stringUnit": {"state": "translated", "value": "Dal tuo storico su questo dispositivo. Le letture non plausibili non contano mai come record."}}, "pl": {"stringUnit": {"state": "translated", "value": "Z Twojej własnej historii na tym urządzeniu. Nieprawdopodobne odczyty nigdy nie liczą się jako rekord."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Do seu próprio histórico neste dispositivo. Leituras implausíveis nunca contam como recorde."}}, "ru": {"stringUnit": {"state": "translated", "value": "Из вашей собственной истории на этом устройстве. Неправдоподобные значения никогда не считаются рекордом."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "来自你在本设备上的历史记录。不合理的读数绝不计为记录。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "來自你在本裝置上的歷史記錄。不合理的讀數絕不計為記錄。"}} + } }, + "was %@": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "vorher %@"}}, "es": {"stringUnit": {"state": "translated", "value": "antes %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "avant %@"}}, "it": {"stringUnit": {"state": "translated", "value": "prima %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "wcześniej %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "antes %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "было %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "之前 %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "之前 %@"}} + } }, + "Highest Effort": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Höchste Belastung"}}, "es": {"stringUnit": {"state": "translated", "value": "Mayor esfuerzo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Effort le plus élevé"}}, "it": {"stringUnit": {"state": "translated", "value": "Sforzo massimo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Największy wysiłek"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Maior esforço"}}, "ru": {"stringUnit": {"state": "translated", "value": "Максимальная нагрузка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最高强度"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最高強度"}} + } }, + "Longest sleep": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Längster Schlaf"}}, "es": {"stringUnit": {"state": "translated", "value": "Sueño más largo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Sommeil le plus long"}}, "it": {"stringUnit": {"state": "translated", "value": "Sonno più lungo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Najdłuższy sen"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sono mais longo"}}, "ru": {"stringUnit": {"state": "translated", "value": "Самый долгий сон"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最长睡眠"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最長睡眠"}} + } }, + "Sleep efficiency": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schlafeffizienz"}}, "es": {"stringUnit": {"state": "translated", "value": "Eficiencia del sueño"}}, "fr": {"stringUnit": {"state": "translated", "value": "Efficacité du sommeil"}}, "it": {"stringUnit": {"state": "translated", "value": "Efficienza del sonno"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wydajność snu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Eficiência do sono"}}, "ru": {"stringUnit": {"state": "translated", "value": "Эффективность сна"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "睡眠效率"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "睡眠效率"}} + } }, "Move, relative to tonight": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Move, relative to tonight"}}, "de": {"stringUnit": {"state": "translated", "value": "Bewegung, relativ zu dieser Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "Movimiento, en relación con esta noche"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mouvement, par rapport à cette nuit"}}, "it": {"stringUnit": {"state": "translated", "value": "Movimento, rispetto a questa notte"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ruch, w odniesieniu do tej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Movimento, em relação a esta noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "Движение, относительно этой ночи"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "活动,相对于今晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "活動,相對於今晚"}}}}, "Route and GPS distance, without the heart-rate and calorie summary": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Route and GPS distance, without the heart-rate and calorie summary"}}, "de": {"stringUnit": {"state": "translated", "value": "Route und GPS-Distanz, ohne die Herzfrequenz- und Kalorienübersicht"}}, "es": {"stringUnit": {"state": "translated", "value": "Ruta y distancia GPS, sin el resumen de frecuencia cardiaca y calorías"}}, "fr": {"stringUnit": {"state": "translated", "value": "Itinéraire et distance GPS, sans le récapitulatif de fréquence cardiaque et de calories"}}, "it": {"stringUnit": {"state": "translated", "value": "Percorso e distanza GPS, senza il riepilogo di frequenza cardiaca e calorie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trasa i dystans GPS, bez podsumowania tętna i kalorii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Rota e distância GPS, sem o resumo de frequência cardíaca e calorias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маршрут и GPS-дистанция, без сводки пульса и калорий"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "路线和 GPS 距离,不含心率和卡路里摘要"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "路線和 GPS 距離,不含心率和卡路里摘要"}}}}, "Export Workout Route": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Export Workout Route"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsroute exportieren"}}, "es": {"stringUnit": {"state": "translated", "value": "Exportar ruta del entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Exporter l’itinéraire de la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Esporta il percorso dell’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Eksportuj trasę treningu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Exportar rota do treino"}}, "ru": {"stringUnit": {"state": "translated", "value": "Экспортировать маршрут тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导出运动路线"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯出運動路線"}}}}, diff --git a/Strand/Screens/PersonalRecordsCard.swift b/Strand/Screens/PersonalRecordsCard.swift new file mode 100644 index 0000000000..a2568bfb00 --- /dev/null +++ b/Strand/Screens/PersonalRecordsCard.swift @@ -0,0 +1,125 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// MARK: - Personal bests card +// +// All-time bests from the user's own daily history (best HRV, lowest resting HR, best Charge, longest sleep, +// ...), each with the day it was set and a NEW tag when it fell in the last week. The selection rules live in +// the pure `PersonalRecords` (StrandAnalytics; Kotlin twin com.noop.analytics.PersonalRecords); this file only +// formats and renders. Mirrors the Android `PersonalRecordsCard`. Display-only. + +struct PersonalRecordsCard: View { + let days: [DailyMetric] + + var body: some View { + let today = AnalyticsEngine.dayString(Int(Date().timeIntervalSince1970), + offsetSec: TimeZone.current.secondsFromGMT()) + let records = PersonalRecords.records(days.map { + PersonalRecords.Day(day: $0.day, hrvMs: $0.avgHrv, restingHr: $0.restingHr.map(Double.init), + recovery: $0.recovery, strain: $0.strain, sleepMin: $0.totalSleepMin, + efficiency: $0.efficiency, deepMin: $0.deepMin, remMin: $0.remMin, + steps: $0.steps.map(Double.init)) + }, today: today) + + NoopCard(tint: StrandPalette.gold) { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("All time").strandOverline() + Text("Personal bests") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if records.isEmpty { + Text("Records appear once NOOP has scored a few days.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } else { + ForEach(records, id: \.metric) { row($0) } + Text("From your own history on this device. Implausible readings never count as a record.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + } + + private func row(_ r: PersonalRecords.Record) -> some View { + HStack { + VStack(alignment: .leading, spacing: NoopMetrics.spaceHalf) { + HStack(spacing: NoopMetrics.space2) { + Text(label(r.metric)) + .font(StrandFont.subhead) + .foregroundStyle(accent(r.metric)) + if r.isNew { + Text("NEW").strandOverline().foregroundStyle(StrandPalette.gold) + } + } + Text(verbatim: dayText(r.day)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + Spacer(minLength: NoopMetrics.space2) + VStack(alignment: .trailing, spacing: NoopMetrics.spaceHalf) { + Text(verbatim: valueText(r.metric, r.value)) + .font(StrandFont.number(20)) + .foregroundStyle(StrandPalette.textPrimary) + if let prev = r.previousBest { + Text("was \(valueText(r.metric, prev))") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space2) + .background(StrandPalette.surfaceInset, in: Capsule()) + .accessibilityElement(children: .combine) + } + + private func label(_ m: PersonalRecordMetric) -> String { + switch m { + case .hrv: return String(localized: "HRV") + case .restingHr: return String(localized: "Resting HR") + case .recovery: return String(localized: "Charge") + case .strain: return String(localized: "Highest Effort") + case .sleep: return String(localized: "Longest sleep") + case .efficiency: return String(localized: "Sleep efficiency") + case .deepSleep: return String(localized: "Deep sleep") + case .remSleep: return String(localized: "REM sleep") + case .steps: return String(localized: "Steps") + } + } + + /// The score family a metric belongs to, so each row carries its family colour. + private func accent(_ m: PersonalRecordMetric) -> Color { + switch m { + case .hrv, .restingHr, .recovery: return StrandPalette.chargeColor + case .strain, .steps: return StrandPalette.effortColor + case .sleep, .efficiency, .deepSleep, .remSleep: return StrandPalette.restColor + } + } + + /// The value in its own unit, matching the Android `personalRecordValueText`. + private func valueText(_ m: PersonalRecordMetric, _ v: Double) -> String { + switch m { + case .hrv: return String(format: "%.0f ms", v) + case .restingHr: return String(format: "%.0f bpm", v) + case .recovery: return String(format: "%.0f%%", v) + case .strain: return String(format: "%.1f", v) + case .efficiency: return String(format: "%.0f%%", v * 100) + case .sleep, .deepSleep, .remSleep: + let m = Swift.max(0, Int(v.rounded())) + return m < 60 ? String(localized: "\(m)m") : String(localized: "\(m / 60)h \(m % 60)m") + case .steps: return v.formatted(.number.precision(.fractionLength(0))) + } + } + + private func dayText(_ key: String) -> String { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + guard let d = f.date(from: key) else { return key } + return d.formatted(date: .abbreviated, time: .omitted) + } +} diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 48e3a5e8df..576a5b5d9f 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -269,6 +269,7 @@ struct SettingsView: View { liveNotificationsCard.staggeredAppear(index: 3) #endif streakCard.staggeredAppear(index: 4) + PersonalRecordsCard(days: model.repo.days).staggeredAppear(index: 4) featuresCard.staggeredAppear(index: 5) #if os(iOS) syncCard.staggeredAppear(index: 6) diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 767e162915..55482c6cb8 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,14 +18,14 @@ ] }, "authority": { - "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, - "functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"}, - "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, - "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, - "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, - "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, + "files": {"count": 511, "sha256": "4491421fd6069aa7c66397ea70362c2e72fd49adf09a6843e21775f2161ce292"}, + "functions": {"count": 4541, "sha256": "2f95abf6a7c664ab66f26ae20652eab8b68bbaad445fce81f6a76e1e85ceb683"}, + "properties": {"count": 492, "sha256": "aada5148a3e0aa6b4da48963b6c9bda58b41bca39fcb3e4ee254b8e55e2f164c"}, + "constants": {"count": 2005, "sha256": "3eef1819ca702de6bc67d9087f6d7b0180a0abdecc0642504e459e468c252f54"}, + "file_pairs": {"count": 82, "sha256": "8898ba6b30763ee61f1c5ed363463950b8ecb9875f7b575d93f9dac06a95a323"}, + "function_pairs": {"count": 228, "sha256": "192559bfe7c3fe3aa299a644463ce6cb15be595f62a0195827840561422adf6c"}, + "property_pairs": {"count": 165, "sha256": "d3a37a406cd3c4ddece9a063e61c2e208149b1144999c8ab3b7f7a0925fdbc7f"}, + "constant_pairs": {"count": 704, "sha256": "86a3b8bfda66e18b9b1178724fdacdab942bb6c53cb2ad359b5dfc4237801256"}, "unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, "unpaired_functions": {"count": 4098, "sha256": "a7c3215172bf4d1cd33b8df390684d4d65fc1109f8ee52b3d344b04d231f08fd"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, diff --git a/android/app/src/main/java/com/noop/analytics/PersonalRecords.kt b/android/app/src/main/java/com/noop/analytics/PersonalRecords.kt new file mode 100644 index 0000000000..36aa552f92 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/PersonalRecords.kt @@ -0,0 +1,138 @@ +package com.noop.analytics + +/** One tracked metric. [raw] matches the Swift `PersonalRecordMetric` raw value. */ +enum class PersonalRecordMetric(val raw: String) { + HRV("hrv"), + RESTING_HR("restingHr"), + RECOVERY("recovery"), + STRAIN("strain"), + SLEEP("sleep"), + EFFICIENCY("efficiency"), + DEEP_SLEEP("deepSleep"), + REM_SLEEP("remSleep"), + STEPS("steps"), + ; + + /** True only for resting HR. */ + val lowerIsBetter: Boolean get() = this == RESTING_HR + + /** Inclusive plausibility range; values outside it are ignored. */ + val range: ClosedFloatingPointRange get() = when (this) { + HRV -> 5.0..300.0 + RESTING_HR -> 25.0..120.0 + RECOVERY -> 0.0..100.0 + STRAIN -> 0.0..21.0 + SLEEP -> 60.0..960.0 + EFFICIENCY -> 0.5..1.0 + DEEP_SLEEP, REM_SLEEP -> 1.0..360.0 + STEPS -> 1.0..100_000.0 + } +} + +/** + * PersonalRecords - all-time personal bests over the user's own daily history. + * + * The WHOOP app has a "personal records" surface, but its records are computed server-side and limited + * to weightlifting exercises (observed in the shipped app's model shapes; facts only, no WHOOP code). + * NOOP has no server, so this is the local equivalent over the daily metrics NOOP already stores: the + * best HRV, the lowest resting HR, the best Charge, the highest strain, the longest sleep and so on. + * + * Rules (Swift twin: StrandAnalytics/PersonalRecords.swift, held aligned by mirrored fixtures): + * - each metric has a plausibility range; values outside it never become a record, so one bad night + * (a 400 ms RMSSD artefact, a 12-minute "sleep") cannot sit at the top of the list forever; + * - higher is better for every metric except resting HR, where lower is better; + * - a tie does not beat a record: the EARLIEST day holding the best value keeps it; + * - a record is "new" when it was set within the last [NEW_WINDOW_DAYS] days ending at today AND at + * least [MIN_PRIOR_VALUES] usable values came before it, so the first weeks of history do not mark + * every metric as new. + * + * Civil-day arithmetic is TZ-free ([Baselines.isoEpochDay]). Pure: no clock, no I/O, no storage. + * PRESENTATION ONLY: nothing here feeds a score, a baseline or a stored value. + */ +object PersonalRecords { + + /** Days (inclusive of today) a record counts as new. */ + const val NEW_WINDOW_DAYS: Int = 7 + + /** Usable values that must precede a record before it can be called new. */ + const val MIN_PRIOR_VALUES: Int = 14 + + /** One day of input. Efficiency may be a fraction (0.91) or a percent (91); it is read as a fraction. */ + data class Day( + val day: String, + val hrvMs: Double? = null, + val restingHr: Double? = null, + val recovery: Double? = null, + val strain: Double? = null, + val sleepMin: Double? = null, + val efficiency: Double? = null, + val deepMin: Double? = null, + val remMin: Double? = null, + val steps: Double? = null, + ) + + /** The best value of one metric, the day it was set and the best that stood before it (null if none). */ + data class Record( + val metric: PersonalRecordMetric, + val value: Double, + val day: String, + val previousBest: Double?, + val isNew: Boolean, + /** Usable values the record was chosen from. */ + val samples: Int, + ) + + /** Read [metric] off [d], normalising efficiency to a fraction. Swift twin: `PersonalRecords.value`. */ + fun value(d: Day, metric: PersonalRecordMetric): Double? = when (metric) { + PersonalRecordMetric.HRV -> d.hrvMs + PersonalRecordMetric.RESTING_HR -> d.restingHr + PersonalRecordMetric.RECOVERY -> d.recovery + PersonalRecordMetric.STRAIN -> d.strain + PersonalRecordMetric.SLEEP -> d.sleepMin + PersonalRecordMetric.EFFICIENCY -> d.efficiency?.let { if (it > 1.0) it / 100.0 else it } + PersonalRecordMetric.DEEP_SLEEP -> d.deepMin + PersonalRecordMetric.REM_SLEEP -> d.remMin + PersonalRecordMetric.STEPS -> d.steps + } + + /** + * One [Record] per metric that has at least one usable value, in [PersonalRecordMetric] order. Rows with an + * unparseable day key are ignored; rows may arrive in any order. [today] is an ISO `yyyy-MM-dd` key; + * when it does not parse, nothing is marked new. + * Swift twin: `PersonalRecords.records`. + */ + fun records(days: List, today: String): List { + // Chronological by civil day; stable, so same-day duplicates keep their input order. + val dated = days.mapNotNull { d -> Baselines.isoEpochDay(d.day)?.let { it to d } }.sortedBy { it.first } + val todayEpoch = Baselines.isoEpochDay(today) + val out = ArrayList() + for (metric in PersonalRecordMetric.entries) { + var best: Double? = null + var bestDay: String? = null + var bestEpoch = 0 + var previous: Double? = null + var priorCount = 0 + var samples = 0 + for ((epoch, d) in dated) { + val v = value(d, metric) ?: continue + if (v.isNaN() || v !in metric.range) continue + val cur = best + val beats = cur == null || (if (metric.lowerIsBetter) v < cur else v > cur) + if (beats) { + previous = cur + best = v + bestDay = d.day + bestEpoch = epoch + priorCount = samples + } + samples += 1 + } + val b = best ?: continue + val isNew = todayEpoch != null && + bestEpoch <= todayEpoch && bestEpoch > todayEpoch - NEW_WINDOW_DAYS && + priorCount >= MIN_PRIOR_VALUES + out.add(Record(metric, b, bestDay!!, previous, isNew, samples)) + } + return out + } +} diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index c76e2c8ed9..c97c9b2444 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -237,6 +237,34 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { com.noop.analytics.StreakCalculator.Streaks(0, 0), ) + /** All-time personal bests over the same merged daily metrics as [streaks] (local equivalent of the + * WHOOP app's server-side records). Pure math lives in [com.noop.analytics.PersonalRecords] + * (Swift/Kotlin twin); display-only. */ + val personalRecords: StateFlow> = + repository.daysMergedFlow(noopApp.activeDeviceId) + .map { days -> + val nowSec = System.currentTimeMillis() / 1000L + val tz = java.util.TimeZone.getDefault().getOffset(nowSec * 1000L) / 1000L + com.noop.analytics.PersonalRecords.records( + days.map { + com.noop.analytics.PersonalRecords.Day( + day = it.day, + hrvMs = it.avgHrv, + restingHr = it.restingHr?.toDouble(), + recovery = it.recovery, + strain = it.strain, + sleepMin = it.totalSleepMin, + efficiency = it.efficiency, + deepMin = it.deepMin, + remMin = it.remMin, + steps = it.steps?.toDouble(), + ) + }, + today = com.noop.analytics.AnalyticsEngine.dayString(nowSec, tz), + ) + } + .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000L), emptyList()) + /** #1410: record an `APP_VERSION_CHANGED` event on the first launch after an update. `"noop-app"` is a * synthetic, non-strap deviceId sentinel (strap ids are BLE UUIDs, so it can't collide). */ private suspend fun recordAppVersionChange() { diff --git a/android/app/src/main/java/com/noop/ui/PersonalRecordsCard.kt b/android/app/src/main/java/com/noop/ui/PersonalRecordsCard.kt new file mode 100644 index 0000000000..b74fb499eb --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/PersonalRecordsCard.kt @@ -0,0 +1,114 @@ +package com.noop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import com.noop.R +import com.noop.analytics.PersonalRecords +import com.noop.analytics.PersonalRecordMetric as Metric +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +// MARK: - Personal bests card +// +// All-time bests from the user's own daily history (best HRV, lowest resting HR, best Charge, longest +// sleep, ...), each with the day it was set and a NEW tag when it fell in the last week. The selection +// rules live in the pure analytics PersonalRecords (Swift twin in StrandAnalytics); this file only +// formats and renders. Display-only: nothing here feeds a score or a stored value. + +/** The record's value in its own unit, e.g. "84 ms", "46 bpm", "7h 52m", "12,480". */ +internal fun personalRecordValueText(metric: Metric, value: Double): String = when (metric) { + Metric.HRV -> String.format(Locale.getDefault(), "%.0f ms", value) + Metric.RESTING_HR -> String.format(Locale.getDefault(), "%.0f bpm", value) + Metric.RECOVERY -> String.format(Locale.getDefault(), "%.0f%%", value) + Metric.STRAIN -> String.format(Locale.getDefault(), "%.1f", value) + Metric.EFFICIENCY -> String.format(Locale.getDefault(), "%.0f%%", value * 100.0) + Metric.SLEEP, Metric.DEEP_SLEEP, Metric.REM_SLEEP -> durationText(value) + Metric.STEPS -> String.format(Locale.getDefault(), "%,.0f", value) +} + +/** A `yyyy-MM-dd` key as a medium localized date; the key itself if it does not parse. */ +internal fun personalRecordDayText(day: String): String = + runCatching { LocalDate.parse(day).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) } + .getOrDefault(day) + +@Composable +private fun Metric.localizedLabel(): String = when (this) { + Metric.HRV -> uiString(R.string.l10n_personal_records_hrv) + Metric.RESTING_HR -> uiString(R.string.l10n_personal_records_resting_hr) + Metric.RECOVERY -> uiString(R.string.l10n_personal_records_charge) + Metric.STRAIN -> uiString(R.string.l10n_personal_records_strain) + Metric.SLEEP -> uiString(R.string.l10n_personal_records_sleep) + Metric.EFFICIENCY -> uiString(R.string.l10n_personal_records_efficiency) + Metric.DEEP_SLEEP -> uiString(R.string.l10n_personal_records_deep_sleep) + Metric.REM_SLEEP -> uiString(R.string.l10n_personal_records_rem_sleep) + Metric.STEPS -> uiString(R.string.l10n_personal_records_steps) +} + +/** The score family a metric belongs to, so each row carries its family colour. */ +@Composable +private fun Metric.accent(): Color = when (this) { + Metric.HRV, Metric.RESTING_HR, Metric.RECOVERY -> Palette.chargeColor + Metric.STRAIN, Metric.STEPS -> Palette.effortColor + Metric.SLEEP, Metric.EFFICIENCY, Metric.DEEP_SLEEP, Metric.REM_SLEEP -> Palette.restColor +} + +@Composable +internal fun PersonalRecordsCard(records: List) { + NoopCard(padding = Metrics.cardPadding, tint = Palette.gold) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space8)) { + Column { + Overline(uiString(R.string.l10n_personal_records_overline)) + Text(uiString(R.string.l10n_personal_records_title), style = NoopType.headline, color = Palette.textPrimary) + } + if (records.isEmpty()) { + Text(uiString(R.string.l10n_personal_records_empty), style = NoopType.footnote, color = Palette.textSecondary) + return@Column + } + records.forEach { r -> PersonalRecordRow(r) } + Text(uiString(R.string.l10n_personal_records_footnote), style = NoopType.footnote, color = Palette.textTertiary) + } + } +} + +@Composable +private fun PersonalRecordRow(r: PersonalRecords.Record) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Metrics.cornerPill)) + .background(Palette.surfaceInset) + .padding(horizontal = Metrics.space14, vertical = Metrics.space8), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Metrics.space8)) { + Text(r.metric.localizedLabel(), style = NoopType.subhead, color = r.metric.accent()) + if (r.isNew) Overline(uiString(R.string.l10n_personal_records_new_badge), color = Palette.gold) + } + Text(personalRecordDayText(r.day), style = NoopType.footnote, color = Palette.textTertiary) + } + Column(horizontalAlignment = Alignment.End) { + Text(personalRecordValueText(r.metric, r.value), style = NoopType.chartValue, color = Palette.textPrimary) + r.previousBest?.let { prev -> + Text( + uiString(R.string.l10n_personal_records_previous, personalRecordValueText(r.metric, prev)), + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 5329b464e7..ae57fa6923 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -954,6 +954,9 @@ fun SettingsScreen( ) } } + // Personal bests: all-time records from the same merged daily metrics as the streak above. + val personalRecords by vm.personalRecords.collectAsStateWithLifecycle() + PersonalRecordsCard(personalRecords) SettingsCard( icon = Icons.Outlined.AccountCircle, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8399bc377c..b01afe23dc 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2873,4 +2873,19 @@ Auf diesem Strap läuft möglicherweise noch eine Aufzeichnung. Das Ergebnis steht im Strap-Log. Erscheint heute, wenn Live Sessions in den Einstellungen aktiviert sind oder eine Session aktiv ist. Today nach Ihren Wünschen, Herzfrequenz für jede App und Italienisch + Persönliche Bestwerte + Gesamter Zeitraum + Bestwerte erscheinen, sobald NOOP ein paar Tage bewertet hat. + Aus deinem eigenen Verlauf auf diesem Gerät. Unplausible Messwerte zählen nie als Bestwert. + NEU + vorher %1$s + HRV + Ruhepuls + Ladung + Höchste Anstrengung + Längster Schlaf + Schlafeffizienz + Tiefschlaf + REM-Schlaf + Schritte diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 20aed28131..5cb466d1b5 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2860,4 +2860,19 @@ Puede que siga activa una captura en esta correa. El veredicto se registra en el strap log. Aparece hoy si Live Sessions está activado en Ajustes o hay una sesión activa. Hoy a tu manera, frecuencia cardíaca para cualquier aplicación e italiano + Récords personales + Todo el historial + Los récords aparecen cuando NOOP ha puntuado unos días. + De tu propio historial en este dispositivo. Las lecturas inverosímiles nunca cuentan como récord. + NUEVO + antes %1$s + VFC + FC en reposo + Carga + Mayor esfuerzo + Sueño más largo + Eficiencia del sueño + Sueño profundo + Sueño REM + Pasos diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bd1c4cc3b1..0e79587267 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2859,4 +2859,19 @@ Une capture est peut-être encore active sur ce bracelet. Le verdict est écrit dans le strap log. Apparaît aujourd’hui si les sessions en direct sont activées dans Paramètres ou si une session est active. Aujourd\'hui à votre façon, fréquence cardiaque lisible par toute application, et l\'italien + Records personnels + Depuis le début + Les records apparaissent dès que NOOP a noté quelques jours. + Issus de votre propre historique sur cet appareil. Les mesures invraisemblables ne comptent jamais comme record. + NOUVEAU + avant %1$s + VFC + FC au repos + Charge + Effort le plus élevé + Sommeil le plus long + Efficacité du sommeil + Sommeil profond + Sommeil paradoxal + Pas diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 1a47561f99..4f3143be86 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2828,4 +2828,19 @@ Una acquisizione potrebbe essere ancora attiva su questo strap. Il verdetto va nello strap log. Compare oggi se le sessioni in diretta sono attivate nelle Impostazioni o una sessione è attiva. Oggi a modo tuo, frequenza cardiaca leggibile da ogni app e l\'italiano + Record personali + Da sempre + I record compaiono quando NOOP ha valutato qualche giorno. + Dalla tua cronologia su questo dispositivo. Le letture non plausibili non contano mai come record. + NUOVO + prima %1$s + HRV + FC a riposo + Carica + Sforzo massimo + Sonno più lungo + Efficienza del sonno + Sonno profondo + Sonno REM + Passi diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index b5e9c52de8..03591d975b 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2876,4 +2876,19 @@ Na tym pasku może nadal trwać zapis. Werdykt trafia do strap log. Pojawia się dzisiaj, gdy sesje na żywo są włączone w Ustawieniach lub trwa sesja. Dziś po swojemu, tętno dostępne dla każdej aplikacji i język włoski + Rekordy osobiste + Cała historia + Rekordy pojawią się, gdy NOOP oceni kilka dni. + Z Twojej własnej historii na tym urządzeniu. Nieprawdopodobne odczyty nigdy nie są rekordem. + NOWY + wcześniej %1$s + HRV + Tętno spoczynkowe + Energia + Największy wysiłek + Najdłuższy sen + Wydajność snu + Sen głęboki + Sen REM + Kroki 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 b64877aeb3..4af016939c 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2852,4 +2852,19 @@ Pode ainda estar a decorrer uma captura nesta pulseira. O veredicto vai para o strap log. Aparece hoje quando as sessões em direto estão ativadas nas Definições ou existe uma sessão ativa. Hoje à sua maneira, frequência cardíaca para qualquer aplicação e italiano + Recordes pessoais + Todo o histórico + Os recordes aparecem quando o NOOP tiver pontuado alguns dias. + Do seu próprio histórico neste dispositivo. Leituras implausíveis nunca contam como recorde. + NOVO + antes %1$s + VFC + FC em repouso + Carga + Maior esforço + Sono mais longo + Eficiência do sono + Sono profundo + Sono REM + Passos diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 6fbec7268f..70d76176fb 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2755,4 +2755,19 @@ На этом ремешке запись может всё ещё идти. Результат попадёт в strap log. Появляется сегодня, если сессии в реальном времени включены в Настройках или сеанс уже активен. «Сегодня» на ваш вкус, пульс для любого приложения и итальянский язык + Личные рекорды + За всё время + Рекорды появятся, когда NOOP оценит несколько дней. + Из вашей собственной истории на этом устройстве. Неправдоподобные значения никогда не считаются рекордом. + НОВЫЙ + было %1$s + ВСР + Пульс покоя + Заряд + Максимальное усилие + Самый долгий сон + Эффективность сна + Глубокий сон + Фаза REM + Шаги diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 75a9e8a04f..8db5252bb7 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2830,4 +2830,19 @@ 此腕带上可能仍在采集。结论会写入 strap log。 在设置中启用Live Sessions 实时记录或有正在进行的训练时,今天会显示此部分。 自定义「今天」页面、任何应用都能读取的心率,以及意大利语 + 个人最佳 + 全部时间 + NOOP 为几天评分后会显示记录。 + 来自您在本设备上的历史数据。不合理的读数绝不计为记录。 + 新 + 之前 %1$s + HRV + 静息心率 + 充能 + 最高负荷 + 最长睡眠 + 睡眠效率 + 深睡 + REM 睡眠 + 步数 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5ae979343a..a75c6dd063 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2889,4 +2889,19 @@ A capture may still be running on this strap. The verdict goes to the strap log. Appears today when Live Sessions is enabled in Settings or a session is active. Today your way, heart rate any app can read, and Italian + Personal bests + All time + Records appear once NOOP has scored a few days. + From your own history on this device. Implausible readings never count as a record. + NEW + was %1$s + HRV + Resting HR + Charge + Highest Effort + Longest sleep + Sleep efficiency + Deep sleep + REM sleep + Steps diff --git a/android/app/src/test/java/com/noop/analytics/PersonalRecordsTest.kt b/android/app/src/test/java/com/noop/analytics/PersonalRecordsTest.kt new file mode 100644 index 0000000000..28f71cf6f9 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/PersonalRecordsTest.kt @@ -0,0 +1,123 @@ +package com.noop.analytics + +import com.noop.analytics.PersonalRecords.Day +import com.noop.analytics.PersonalRecordMetric as Metric +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Mirrors StrandAnalyticsTests/PersonalRecordsTests.swift - same fixtures, same expected outputs. */ +class PersonalRecordsTest { + + private val today = "2026-10-20" + + /** `n` consecutive days ending at [end], HRV from [hrv] by index. */ + private fun daysRun(n: Int, end: String, hrv: (Int) -> Double?): List { + val endEpoch = Baselines.isoEpochDay(end)!! + return (0 until n).map { i -> Day(day = keyFor(endEpoch - (n - 1) + i), hrvMs = hrv(i)) } + } + + private fun keyFor(epoch: Int): String = java.time.LocalDate.ofEpochDay(epoch.toLong()).toString() + + private fun record(days: List, metric: Metric, today: String = this.today) = + PersonalRecords.records(days, today).firstOrNull { it.metric == metric } + + @Test fun emptyHistoryHasNoRecords() = assertEquals(emptyList(), PersonalRecords.records(emptyList(), today)) + + @Test fun highestHrvWinsAndKeepsPreviousBest() { + val r = record(daysRun(5, "2026-10-01") { listOf(40.0, 55.0, 50.0, 70.0, 60.0)[it] }, Metric.HRV)!! + assertEquals(70.0, r.value, 0.0) + assertEquals("2026-09-30", r.day) + assertEquals(55.0, r.previousBest!!, 0.0) + assertEquals(5, r.samples) + assertFalse(r.isNew) + } + + @Test fun restingHrLowerIsBetter() { + val days = listOf( + Day("2026-10-01", restingHr = 52.0), + Day("2026-10-02", restingHr = 47.0), + Day("2026-10-03", restingHr = 49.0), + ) + val r = record(days, Metric.RESTING_HR)!! + assertEquals(47.0, r.value, 0.0) + assertEquals("2026-10-02", r.day) + assertEquals(52.0, r.previousBest!!, 0.0) + } + + @Test fun tieKeepsEarliestDay() { + val r = record(daysRun(3, "2026-10-01") { 60.0 }, Metric.HRV)!! + assertEquals("2026-09-29", r.day) + assertNull(r.previousBest) + } + + @Test fun implausibleValuesAreIgnored() { + val days = listOf( + Day("2026-10-01", hrvMs = 400.0, sleepMin = 12.0, restingHr = 10.0), + Day("2026-10-02", hrvMs = 65.0, sleepMin = 480.0, restingHr = 50.0), + ) + assertEquals(65.0, record(days, Metric.HRV)!!.value, 0.0) + assertEquals(480.0, record(days, Metric.SLEEP)!!.value, 0.0) + assertEquals(50.0, record(days, Metric.RESTING_HR)!!.value, 0.0) + assertEquals(1, record(days, Metric.HRV)!!.samples) + } + + @Test fun efficiencyPercentReadsAsFraction() { + val days = listOf(Day("2026-10-01", efficiency = 91.0), Day("2026-10-02", efficiency = 0.88)) + assertEquals(0.91, record(days, Metric.EFFICIENCY)!!.value, 1e-12) + } + + @Test fun unorderedInputAndBadKeys() { + val days = listOf( + Day("2026-10-03", hrvMs = 50.0), + Day("not-a-day", hrvMs = 99.0), + Day("2026-10-01", hrvMs = 50.0), + Day("2026-10-02", hrvMs = 45.0), + ) + val r = record(days, Metric.HRV)!! + assertEquals("2026-10-01", r.day) + assertEquals(3, r.samples) + } + + @Test fun newRecordNeedsRecentDayAndEnoughHistory() { + // 20 days ending today, best on the last day: 19 prior values >= 14 -> new. + val r = record(daysRun(20, today) { if (it == 19) 90.0 else 50.0 + it }, Metric.HRV)!! + assertEquals(today, r.day) + assertTrue(r.isNew) + assertEquals(68.0, r.previousBest!!, 0.0) + } + + @Test fun recordInFirstWeeksIsNotNew() { + val r = record(daysRun(10, today) { 50.0 + it }, Metric.HRV)!! + assertEquals(today, r.day) + assertFalse(r.isNew) + } + + @Test fun newWindowIsSevenDaysInclusive() { + // Best set 6 days before today -> new; 7 days before -> not new. + val six = record(daysRun(20, "2026-10-14") { if (it == 19) 90.0 else 50.0 }, Metric.HRV)!! + assertTrue(six.isNew) + val seven = record(daysRun(20, "2026-10-13") { if (it == 19) 90.0 else 50.0 }, Metric.HRV)!! + assertFalse(seven.isNew) + } + + @Test fun futureDayIsNeverNew() { + val r = record(daysRun(20, "2026-10-22") { if (it == 19) 90.0 else 50.0 }, Metric.HRV)!! + assertFalse(r.isNew) + } + + @Test fun unparseableTodayMarksNothingNew() { + val r = record(daysRun(20, today) { if (it == 19) 90.0 else 50.0 }, Metric.HRV, today = "bad")!! + assertFalse(r.isNew) + } + + @Test fun metricsWithoutValuesAreOmittedAndOrderIsStable() { + val days = listOf(Day("2026-10-01", steps = 12_000.0, strain = 14.2, hrvMs = 61.0)) + assertEquals( + listOf(Metric.HRV, Metric.STRAIN, Metric.STEPS), + PersonalRecords.records(days, today).map { it.metric }, + ) + } +}