diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift index e4bca676a4..1302c91b75 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRZones.swift @@ -152,6 +152,21 @@ public enum HRZones { return HRZoneSet(zones: built, maxHR: maxHR, source: custom == nil ? source : "custom") } + /// The five Karvonen heart-rate-RESERVE zones (50/60/70/80/90 % HRR), as bpm bounds: lower = + /// restingHR + pct · (maxHR − restingHR). The model WHOOP's Healthspan zone targets are stated in and + /// the one `StrainScorer` already uses for strain, so a Healthspan zone minute and a strain zone + /// minute are the same minute. Nil when the reserve is not positive. + public static func heartRateReserveZones(restingHR: Double, maxHR: Double) -> HRZoneSet? { + let reserve = maxHR - restingHR + guard restingHR > 0, reserve > 0 else { return nil } + let bounds = zoneEdges.map { restingHR + $0 * reserve } + let built = (0..<5).map { i in + HRZone(number: i + 1, lower: bounds[i], upper: bounds[i + 1], + lowerPct: bounds[i] / maxHR, upperPct: bounds[i + 1] / maxHR) + } + return HRZoneSet(zones: built, maxHR: maxHR, source: "hrr") + } + /// The conventional five inclusive lower bounds, rounded up to whole BPM for an editor. Rounding /// up preserves the existing integer-sample classification (e.g. a 93.5 edge starts at 94 bpm). public static func defaultLowerBounds(maxHR: Double) -> [Int] { diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PaceOfAgingEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PaceOfAgingEngine.swift index 94d8a91e85..27bb9ed03c 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/PaceOfAgingEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PaceOfAgingEngine.swift @@ -26,11 +26,6 @@ import Foundation // the margin's scale: ± 1.96 · sd / 0.5 y. With fewer than three windows the spread is not measurable and // a prior (`priorWindowSD`) is used, flagged `lowerConfidence`. When the margin covers 1× the honest // readout is "holding steady", not a figure. -// -// ── THE LEGACY FIT (`compute(samples:)`) ────────────────────────────────────────────────────────── -// -// The ordinary-least-squares drift fit below predates the projection and is kept only until the -// analytics pipeline moves to `project`; nothing new should call it. public enum PaceOfAgingEngine { // MARK: - Projection (WHOOP's definition) @@ -101,69 +96,17 @@ public enum PaceOfAgingEngine { lowerConfidence: !measured, comparedKeys: common.sorted()) } - // MARK: - Legacy drift fit (see the header) + // MARK: - Shared constants - - /// Days of history rolled into ONE sample of S. A habit, not a night. - public static let recentWindowDays = 30 - /// How far back the slope is fitted. Older samples than this are ignored even when present. - public static let trendWindowDays = 90 - /// Fewest DAYS that must actually carry data inside one rolling window before it may become a sample. - /// - /// A window is meant to be a habit. Near the start of someone's history the same 30-day span holds two - /// or three days, and the S computed from it is a far noisier quantity than the S computed from thirty - /// — same units, same name, different thing. Fitting a trend through both reads the ordinary spread of - /// the thin end as a direction. Callers assembling samples enforce this; `usableSamples` cannot, since - /// a sample arrives already reduced to one number. + /// Fewest scored DAYS a 30-day window must hold before it counts as one month of habits. Near the + /// start of someone's history the same span holds two or three days, and a Body Age computed from + /// them is a far noisier quantity than one from thirty — same units, same name, different thing. public static let minWindowDays = 15 - /// Fewest usable samples before a pace is reported at all. - public static let minSamples = 60 - /// WHOOP presents the same idea on a −1× … 3× scale; the clamp keeps a sparse or extreme fit on it. + /// WHOOP presents Pace of Aging on a −1× … 3× scale; the clamp keeps an extreme month on it. public static let minPace = -1.0, maxPace = 3.0 - /// Days per year used to annualise a per-day slope. - static let daysPerYear = 365.25 - /// Two-sided 95% normal critical value — the bar a slope clears before it is called a direction. + /// Two-sided 95 % normal critical value. static let steadyZ = 1.96 - /// One day's rolling-30-day summed log-hazard, as `VitalityEngine.Result.lnHazardSum` reports it. - public struct Sample: Equatable, Sendable { - /// Days since any fixed epoch — only differences between samples matter. - public let dayIndex: Int - public let lnHazardSum: Double - /// The factors behind this sample, sorted and joined (e.g. "hrv,rhr,sleep,steps"). Samples whose - /// signature differs from the newest one are not comparable and are not fitted. See the header. - public let factorSignature: String - - public init(dayIndex: Int, lnHazardSum: Double, factorSignature: String) { - self.dayIndex = dayIndex; self.lnHazardSum = lnHazardSum - self.factorSignature = factorSignature - } - } - - public struct Result: Equatable, Sendable { - /// Body Age years gained per calendar year, clamped to [minPace, maxPace]. 1 = holding steady. - public let pace: Double - /// Drift of the summed log-hazard, per year — the raw fitted slope before rescaling. - public let slopeLnPerYear: Double - /// Standard error of that slope, same units. - public let standardErrorLnPerYear: Double - /// ± band on `pace` at 95%, in the same × units, so a UI can show the number with its uncertainty. - public let paceMargin: Double - /// True when the slope is not distinguishable from zero at 95% — show "holding steady", not a figure. - public let isSteady: Bool - public let samplesUsed: Int - /// True when fewer than `trendWindowDays` samples were available, so the fit rests on a short span. - public let lowerConfidence: Bool - - public init(pace: Double, slopeLnPerYear: Double, standardErrorLnPerYear: Double, - paceMargin: Double, isSteady: Bool, samplesUsed: Int, lowerConfidence: Bool) { - self.pace = pace; self.slopeLnPerYear = slopeLnPerYear - self.standardErrorLnPerYear = standardErrorLnPerYear; self.paceMargin = paceMargin - self.isSteady = isSteady; self.samplesUsed = samplesUsed - self.lowerConfidence = lowerConfidence - } - } - /// Day index for a "yyyy-MM-dd" key: whole days since 1970-01-01, counted on a fixed UTC calendar. /// /// UTC deliberately, and it is not a bug that the day keys themselves are LOCAL days. The index is @@ -181,67 +124,4 @@ public enum PaceOfAgingEngine { guard let date = cal.date(from: comps) else { return nil } return Int((date.timeIntervalSince1970 / 86_400).rounded()) } - - /// The samples a fit would actually use: the newest `trendWindowDays` days, restricted to the newest - /// contiguous run whose factor signature matches the newest sample's. Exposed so the readiness - /// countdown and the fit can never disagree about what counts. - public static func usableSamples(_ samples: [Sample]) -> [Sample] { - let sorted = samples.sorted { $0.dayIndex < $1.dayIndex } - guard let newest = sorted.last else { return [] } - var run: [Sample] = [] - for s in sorted.reversed() { - guard s.factorSignature == newest.factorSignature else { break } - guard newest.dayIndex - s.dayIndex < trendWindowDays else { break } - run.append(s) - } - return run.reversed() - } - - /// Samples still needed before a pace can be reported at all — the countdown a not-ready card shows. - /// Assumes continued daily wear; a gap simply doesn't advance the count. - public static func daysUntilReady(_ samples: [Sample]) -> Int { - max(0, minSamples - usableSamples(samples).count) - } - - /// Fit the pace of aging. Nil until `minSamples` comparable samples exist, or when every sample falls - /// on one day (no time base to fit a slope against). - public static func compute(samples: [Sample]) -> Result? { - let used = usableSamples(samples) - guard used.count >= minSamples else { return nil } - - let n = Double(used.count) - let xs = used.map { Double($0.dayIndex) } - let ys = used.map { $0.lnHazardSum } - let meanX = xs.reduce(0, +) / n - let meanY = ys.reduce(0, +) / n - var sxx = 0.0, sxy = 0.0 - for i in 0.. 0 else { return nil } // every sample on one day - - let slopePerDay = sxy / sxx - let intercept = meanY - slopePerDay * meanX - // Residual standard error of the slope: sqrt( SSE/(n−2) / Sxx ). - var sse = 0.0 - for i in 0.. Session? { + guard endTs > startTs else { return nil } + var wake: [Span] = [] + if let data = stagesJSON?.data(using: .utf8), + let segments = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { + for seg in segments { + let stage = (seg["stage"] as? String)?.lowercased() + guard stage == "wake" || stage == "awake", + let s = (seg["start"] as? NSNumber)?.doubleValue, + let e = (seg["end"] as? NSNumber)?.doubleValue, e > s else { continue } + wake.append(Span(start: s, end: e)) + } + } + return Session(start: Double(startTs), end: Double(endTs), wake: wake) + } + /// Local sleep day `d` runs from noon of local day `d` (days since 1970-01-01) to the next noon. static func dayStart(_ day: Int, tzOffsetSec: Int) -> Double { Double(day * 86_400 + 43_200 - tzOffsetSec) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift index b04b19d1bb..f1d6ed0687 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRZonesTests.swift @@ -126,3 +126,20 @@ final class HRZonesTests: XCTestCase { XCTAssertEqual(tiz.seconds(inZone: 1), tiz.total, accuracy: 1e-9) // all of it is zone 1 } } + +extension HRZonesTests { + + /// Heart-rate-reserve zones: lower bound = rest + pct · (max − rest), the Karvonen model the Healthspan + /// zone targets are stated in. + func testHeartRateReserveZones() { + let z = HRZones.heartRateReserveZones(restingHR: 60, maxHR: 190)! + XCTAssertEqual(z.zones.map(\.lower), [125, 138, 151, 164, 177]) + XCTAssertEqual(z.zones.last!.upper, 190) + XCTAssertEqual(z.zoneNumber(forBPM: 124), 0) + XCTAssertEqual(z.zoneNumber(forBPM: 151), 3) + XCTAssertEqual(z.zoneNumber(forBPM: 164), 4) + XCTAssertEqual(z.source, "hrr") + XCTAssertNil(HRZones.heartRateReserveZones(restingHR: 190, maxHR: 190)) + XCTAssertNil(HRZones.heartRateReserveZones(restingHR: 0, maxHR: 190)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PaceOfAgingEngineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PaceOfAgingEngineTests.swift index 710aeac8e4..d331413040 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PaceOfAgingEngineTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PaceOfAgingEngineTests.swift @@ -1,125 +1,10 @@ import XCTest @testable import StrandAnalytics -final class PaceOfAgingEngineTests: XCTestCase { - - private let signature = "hrv,rhr,sleep,steps" - - /// A run of samples whose summed log-hazard drifts by `gompertzYearsPerYear` Gompertz-years each - /// calendar year — i.e. the drift that should read as a pace of `1 + gompertzYearsPerYear`. - private func drift(_ gompertzYearsPerYear: Double, days: Int = 90, - signature: String? = nil) -> [PaceOfAgingEngine.Sample] { - (0..90 days older, so nothing else is comparable. - XCTAssertEqual(PaceOfAgingEngine.usableSamples(old).count, 1) - XCTAssertNil(PaceOfAgingEngine.compute(samples: old)) - } - - /// Unordered input must not change the fit — the engine sorts before it windows. - func testOrderIndependent() { - let forward = drift(0.4) - let shuffled = Array(forward.reversed()) - XCTAssertEqual(PaceOfAgingEngine.compute(samples: forward)!.pace, - PaceOfAgingEngine.compute(samples: shuffled)!.pace, accuracy: 1e-12) - } - - /// No time base → no slope, rather than a divide-by-zero dressed as a number. - func testAllSamplesOnOneDayYieldsNil() { - let stacked = (0..<70).map { _ in - PaceOfAgingEngine.Sample(dayIndex: 10, lnHazardSum: 0.2, factorSignature: signature) - } - XCTAssertNil(PaceOfAgingEngine.compute(samples: stacked)) - } - - func testEmptyInput() { - XCTAssertNil(PaceOfAgingEngine.compute(samples: [])) - XCTAssertTrue(PaceOfAgingEngine.usableSamples([]).isEmpty) - XCTAssertEqual(PaceOfAgingEngine.daysUntilReady([]), PaceOfAgingEngine.minSamples) - } -} +/// Pace of Aging as WHOOP defines it: the last 30 days held for six months. Exact values on the edge +/// cases are pinned by `HealthspanOracleTests`; these name the properties. (The v1 least-squares drift +/// fit and its tests were retired with it.) +final class PaceOfAgingEngineTests: XCTestCase {} extension PaceOfAgingEngineTests { @@ -139,16 +24,6 @@ extension PaceOfAgingEngineTests { } } -extension PaceOfAgingEngineTests { - - /// The window floor is a real constraint, not decoration: a 30-day window is meant to be a habit, and - /// half of it is the least that can pass for one. - func testWindowFloorIsHalfTheWindow() { - XCTAssertEqual(PaceOfAgingEngine.minWindowDays, PaceOfAgingEngine.recentWindowDays / 2) - XCTAssertLessThan(PaceOfAgingEngine.minWindowDays, PaceOfAgingEngine.recentWindowDays) - } -} - // MARK: - Projection (WHOOP's definition) extension PaceOfAgingEngineTests { diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRegularityTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRegularityTests.swift index 28ace776dc..ce32bc5dcc 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRegularityTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRegularityTests.swift @@ -74,3 +74,18 @@ final class SleepRegularityTests: XCTestCase { XCTAssertTrue(SleepRegularity.dailyAgreement(sessions: [], tzOffsetSec: tz).isEmpty) } } + +extension SleepRegularityTests { + + /// A stored computed block's "wake" segments become wake spans; an imported minute dictionary keeps + /// the span and carries no wake. + func testSessionFromStoredBlock() { + let json = #"[{"start":1000,"end":1600,"stage":"light"},{"start":1600,"end":1900,"stage":"wake"},{"start":1900,"end":4000,"stage":"deep"}]"# + let s = SleepRegularity.session(startTs: 1000, endTs: 4000, stagesJSON: json)! + XCTAssertEqual(s.wake, [.init(start: 1600, end: 1900)]) + let imported = SleepRegularity.session(startTs: 1000, endTs: 4000, stagesJSON: #"{"awake":12,"light":200}"#)! + XCTAssertTrue(imported.wake.isEmpty) + XCTAssertEqual(imported.span, .init(start: 1000, end: 4000)) + XCTAssertNil(SleepRegularity.session(startTs: 4000, endTs: 4000, stagesJSON: nil)) + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/MetricSeriesStore.swift b/Packages/WhoopStore/Sources/WhoopStore/MetricSeriesStore.swift index 7c9f18c86d..82b7706d76 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/MetricSeriesStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/MetricSeriesStore.swift @@ -50,6 +50,26 @@ extension WhoopStore { return n } + /// Replace every point of `keys` on days in [from, to] with `rows`, in ONE transaction: delete the + /// range, then upsert. For a resolver that recomputes a window of days and may legitimately produce + /// fewer keys than last time (a driver that lost its data), where an upsert alone would leave the old + /// key behind, and a separate delete would expose an empty window between the two writes. Rows whose + /// key is not in `keys` are written too; only `keys` are cleared. Returns rows upserted. + @discardableResult + public func replaceMetricSeries(_ rows: [MetricPoint], deviceId: String, keys: [String], + from: String, to: String) async throws -> Int { + try syncWrite { db in + if !keys.isEmpty { + let marks = Array(repeating: "?", count: keys.count).joined(separator: ",") + try db.execute(sql: """ + DELETE FROM metricSeries + WHERE deviceId = ? AND day >= ? AND day <= ? AND key IN (\(marks)) + """, arguments: StatementArguments([deviceId, from, to] + keys)) + } + return try Self.upsertMetricSeries(rows, deviceId: deviceId, in: db) + } + } + // MARK: - Reads /// Points for a single `key` on days in [from, to] (lexicographic YYYY-MM-DD compare), diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricSeriesStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricSeriesStoreTests.swift index e13e67d6a2..bb1b56e614 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/MetricSeriesStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/MetricSeriesStoreTests.swift @@ -210,3 +210,33 @@ final class MetricSeriesStoreTests: XCTestCase { XCTAssertNil(span) } } + +extension MetricSeriesStoreTests { + + /// A recomputed window that produces fewer keys than last time clears the dropped ones — inside the + /// range, for the named keys, for this device only — and leaves everything else alone. + func testReplaceMetricSeriesClearsOnlyTheNamedKeysInRange() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertMetricSeries([ + MetricPoint(day: "2026-09-01", key: "a", value: 1), + MetricPoint(day: "2026-09-02", key: "a", value: 2), + MetricPoint(day: "2026-09-02", key: "b", value: 3), + MetricPoint(day: "2026-09-02", key: "keep", value: 4), + MetricPoint(day: "2026-09-03", key: "b", value: 5), + ], deviceId: "d") + try await store.upsertMetricSeries([MetricPoint(day: "2026-09-02", key: "b", value: 9)], deviceId: "other") + + let n = try await store.replaceMetricSeries([MetricPoint(day: "2026-09-02", key: "a", value: 20)], + deviceId: "d", keys: ["a", "b"], + from: "2026-09-02", to: "2026-09-02") + XCTAssertEqual(n, 1) + let a = try await store.metricSeries(deviceId: "d", key: "a", from: "0000", to: "9999") + XCTAssertEqual(a.map(\.value), [1, 20], "outside the range untouched, inside replaced") + let b = try await store.metricSeries(deviceId: "d", key: "b", from: "0000", to: "9999") + XCTAssertEqual(b.map(\.day), ["2026-09-03"], "the dropped key is cleared inside the range only") + let keep = try await store.metricSeries(deviceId: "d", key: "keep", from: "0000", to: "9999") + XCTAssertEqual(keep.count, 1, "an unnamed key is never cleared") + let other = try await store.metricSeries(deviceId: "other", key: "b", from: "0000", to: "9999") + XCTAssertEqual(other.count, 1, "another device is never touched") + } +} diff --git a/Strand/Data/HealthspanPipeline.swift b/Strand/Data/HealthspanPipeline.swift new file mode 100644 index 0000000000..fd239301af --- /dev/null +++ b/Strand/Data/HealthspanPipeline.swift @@ -0,0 +1,333 @@ +import Foundation +import StrandAnalytics +import WhoopStore + +// HealthspanPipeline.swift — turns stored history into the daily Healthspan points (Body Age, Pace of +// Aging, and what each driver is worth in years). +// +// ── ONE RESOLVER, EVERY WINDOW ──────────────────────────────────────────────────────────────────── +// +// Body Age reads the last 182 days (WHOOP's six months), the pace compares the last 30 against them, and +// the pace's margin reads the six non-overlapping 30-day windows between. All of them come from +// `healthspanInputs(history:endDay:windowDays:…)`: two builders would let the Body Age and the pace that +// projects it describe differently-shaped people. +// +// ── ONE STORED POINT PER DAY, ONE FUNNEL ───────────────────────────────────────────────────────── +// +// Each unlocked day gets one set of points: the headline (`body_age`, `vitality`), the pace +// (`pace_of_aging`, its margin, the projected Body Age) and, per driver, its years, value, target and +// last-30-day value (`hs_years_` …). A screen showing a driver's years reads the SAME row the +// headline was summed from, so the breakdown cannot disagree with the number it explains. Nothing is +// recomputed on a screen. +// +// ── MODEL VERSION ───────────────────────────────────────────────────────────────────────────────── +// +// The stored rows outlive the model that wrote them. A `healthspan_model` marker row records which model +// the stored history came from; when it is older than `HealthspanSeries.modelVersion`, the whole +// Healthspan history is cleared and re-derived under the current model in one pass, so the trend chart +// never splices two models together at the upgrade date. The pass is idempotent: re-running it produces +// the same rows, and the marker is written only after the rows are. + +/// The metricSeries keys the Healthspan pipeline writes, in one place for writer and readers alike. +enum HealthspanSeries { + /// Bumped whenever a change would make stored history disagree with a fresh computation. + /// 1 = the Saturday-keyed weekly population-referent model; 2 = the target-referent daily model. + static let modelVersion = 2.0 + static let modelKey = "healthspan_model" + /// The marker row's day: a fixed key outside any real history, so it never shows up in a chart. + static let modelDay = "1970-01-01" + + static let bodyAge = "body_age" + static let vitality = "vitality" + static let pace = "pace_of_aging" + static let paceMargin = "pace_of_aging_margin" + static let projectedBodyAge = "body_age_projected" + + /// Every driver key `VitalityEngine.contributions` can produce. + static let drivers = ["rhr", "vo2max", "sleep", "consistency", "steps", + "moderate", "vigorous", "strength", "leanmass"] + static func years(_ driver: String) -> String { "hs_years_\(driver)" } + static func value(_ driver: String) -> String { "hs_value_\(driver)" } + static func target(_ driver: String) -> String { "hs_target_\(driver)" } + static func recent(_ driver: String) -> String { "hs_recent_\(driver)" } + + /// Every key a day's points can carry — what a recomputation clears before it writes. + static let allKeys: [String] = [bodyAge, vitality, pace, paceMargin, projectedBodyAge] + + drivers.flatMap { [years($0), value($0), target($0), recent($0)] } + + /// Per-day heart-rate-reserve zone minutes (Karvonen 50–80 % and ≥ 80 % HRR), the unit the zone + /// targets are stated in. Written by the day scan; the old %HRmax `zone_min_2_3` / `zone_min_4_5` + /// rows are left as they are and no longer read here. + static let zoneModerate = "zone_min_hrr_1_3" + static let zoneVigorous = "zone_min_hrr_4_5" + static let strength = "strength_min" + + /// Days re-derived on a model upgrade. The trend chart shows six months; two years keeps Trends' + /// longer Body Age history without re-deriving a decade of imported data on first launch. + static let rederiveDays = 730 + /// Days recomputed on an ordinary pass: long enough to pick up late-arriving imports and re-scored + /// nights, short enough to be cheap. + static let refreshDays = 7 +} + +/// Everything the resolver reads, keyed by day index (`PaceOfAgingEngine.dayIndex`). +struct HealthspanHistory { + var days: [Int: DailyMetric] = [:] + var zones: [Int: (moderate: Double, vigorous: Double)] = [:] + var strength: [Int: Double] = [:] + /// `SleepRegularity.dailyAgreement`, keyed by the earlier sleep day of each pair. + var sriAgreement: [Int: Double] = [:] + var vo2max: [Int: (value: Double, source: VitalityEngine.VO2maxSource)] = [:] + var leanMass: [Int: Double] = [:] + var weight: [Int: Double] = [:] +} + +extension IntelligenceEngine { + + /// Observed days out of a window before an activity dose is claimed at all. Below this, one logged day + /// would become a whole week's dose. + nonisolated static let healthspanMinActivityDays = 4 + + /// Scale an activity dose observed on `observedDays` of a window up to a week. + /// + /// A window rarely has every day observed, and the targets are stated per week — so the observed days + /// are averaged and scaled, the same treatment the sleep and step means get. Nil below + /// `healthspanMinActivityDays`, because scaling one day by seven is not a measurement. + nonisolated static func healthspanScaledDose(total: Double, observedDays: Int, windowDays: Int) -> Double? { + guard observedDays >= healthspanMinActivityDays, observedDays > 0, windowDays > 0 else { return nil } + return total / Double(observedDays) * Double(windowDays) + } + + nonisolated static func healthspanMedian(_ xs: [Double]) -> Double? { + guard !xs.isEmpty else { return nil } + let s = xs.sorted(), n = s.count + return n % 2 == 1 ? s[n / 2] : (s[n / 2 - 1] + s[n / 2]) / 2 + } + + nonisolated static func healthspanMean(_ xs: [Double]) -> Double? { + xs.isEmpty ? nil : xs.reduce(0, +) / Double(xs.count) + } + + /// Scored days (a night with a resting HR) in the `VitalityEngine.unlockWindowDays` ending `endDay`. + nonisolated static func healthspanScoredDays(_ history: HealthspanHistory, endDay: Int) -> Int { + (0.. VitalityEngine.Inputs { + let span = (endDay - windowDays + 1)...endDay + let days = span.compactMap { history.days[$0] } + let rhrs = days.compactMap { $0.restingHr }.map(Double.init) + let nights = days.compactMap { $0.totalSleepMin }.map { $0 / 60.0 }.filter { $0 > 0 } + let steps = days.compactMap { $0.steps }.map(Double.init) + + let observed = span.filter { history.zones[$0] != nil } + let moderate = observed.reduce(0.0) { $0 + (history.zones[$1]?.moderate ?? 0) } + let vigorous = observed.reduce(0.0) { $0 + (history.zones[$1]?.vigorous ?? 0) } + let strength = observed.reduce(0.0) { $0 + (history.strength[$1] ?? 0) } + + // Pairs whose earlier sleep day starts the evening before the window's first day, through the + // last night that ends inside it. + let sri = SleepRegularity.index(agreement: history.sriAgreement, + fromDay: span.lowerBound - 1, toDay: span.upperBound - 1) + + // An external VO₂max is independent evidence and wins when the window has one. + let vo2 = span.compactMap { history.vo2max[$0] } + let external = vo2.filter { $0.source == .external }.map(\.value) + let strap = vo2.filter { $0.source == .strap }.map(\.value) + + let lean = healthspanMean(span.compactMap { history.leanMass[$0] }) + let weight = healthspanMean(span.compactMap { history.weight[$0] }) ?? profileWeightKg + + return VitalityEngine.Inputs( + chronoAge: age, sex: sex, + restingHR: healthspanMedian(rhrs), + vo2max: healthspanMean(external) ?? healthspanMean(strap), + vo2maxSource: external.isEmpty ? .strap : .external, + sleepHours: healthspanMean(nights), + sleepRegularity: sri, + steps: healthspanMean(steps), + moderateMinPerWeek: healthspanScaledDose(total: moderate, observedDays: observed.count, windowDays: 7), + vigorousMinPerWeek: healthspanScaledDose(total: vigorous, observedDays: observed.count, windowDays: 7), + strengthMinPerWeek: healthspanScaledDose(total: strength, observedDays: observed.count, windowDays: 7), + leanMassKg: lean, + weightKg: lean == nil ? nil : weight) + } + + /// The day's Healthspan points, or empty while Healthspan is locked (fewer than 21 scored days in the + /// last 31, or under 18) or the six-month window cannot be scored. + nonisolated static func healthspanPoints(history: HealthspanHistory, endDay: Int, dayKey: String, + age: Double, sex: String, + profileWeightKg: Double?) -> [MetricPoint] { + let scored = healthspanScoredDays(history, endDay: endDay) + guard VitalityEngine.unlockStatus(scoredDaysInWindow: scored, age: age).unlocked else { return [] } + func inputs(_ end: Int, _ window: Int) -> VitalityEngine.Inputs { + healthspanInputs(history: history, endDay: end, windowDays: window, age: age, sex: sex, + profileWeightKg: profileWeightKg) + } + let baseline = inputs(endDay, PaceOfAgingEngine.baselineWindowDays) + guard let body = VitalityEngine.compute(baseline) else { return [] } + let recent = inputs(endDay, PaceOfAgingEngine.projectionRecentDays) + // The six non-overlapping monthly windows of the half-year, each only when it holds enough scored + // days to be a habit rather than a handful of nights. + let monthly = (0..<6).compactMap { k -> VitalityEngine.Inputs? in + let end = endDay - k * PaceOfAgingEngine.projectionRecentDays + let scoredInWindow = (0..= PaceOfAgingEngine.minWindowDays + ? inputs(end, PaceOfAgingEngine.projectionRecentDays) : nil + } + + var out = [MetricPoint(day: dayKey, key: HealthspanSeries.bodyAge, value: body.bodyAge), + MetricPoint(day: dayKey, key: HealthspanSeries.vitality, value: body.vitality)] + for c in body.contributions { + out.append(MetricPoint(day: dayKey, key: HealthspanSeries.years(c.key), value: c.deltaYears ?? 0)) + out.append(MetricPoint(day: dayKey, key: HealthspanSeries.value(c.key), value: c.value)) + out.append(MetricPoint(day: dayKey, key: HealthspanSeries.target(c.key), value: c.target)) + } + for c in VitalityEngine.contributions(recent) { + out.append(MetricPoint(day: dayKey, key: HealthspanSeries.recent(c.key), value: c.value)) + } + if let p = PaceOfAgingEngine.project(baseline: baseline, recent: recent, monthlyWindows: monthly) { + out.append(MetricPoint(day: dayKey, key: HealthspanSeries.pace, value: p.pace)) + // The margin travels WITH the pace, keyed to the same day. A pace read back without it cannot + // tell a real change from noise, and a dial that cannot tell will state a direction the data + // does not support. + out.append(MetricPoint(day: dayKey, key: HealthspanSeries.paceMargin, value: p.paceMargin)) + out.append(MetricPoint(day: dayKey, key: HealthspanSeries.projectedBodyAge, value: p.projectedBodyAge)) + } + return out + } + + /// Points for every day in `fromDay...toDay`. + nonisolated static func healthspanPoints(history: HealthspanHistory, fromDay: Int, toDay: Int, + age: Double, sex: String, + profileWeightKg: Double?) -> [MetricPoint] { + guard fromDay <= toDay else { return [] } + let fmt = healthspanDayFormatter() + return (fromDay...toDay).flatMap { day in + healthspanPoints(history: history, endDay: day, dayKey: healthspanDayKey(day, fmt), + age: age, sex: sex, profileWeightKg: profileWeightKg) + } + } + + nonisolated static func healthspanDayFormatter() -> DateFormatter { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.timeZone = TimeZone(identifier: "UTC") + fmt.dateFormat = "yyyy-MM-dd" + return fmt + } + + /// The "yyyy-MM-dd" key of a day index — the inverse of `PaceOfAgingEngine.dayIndex`. + nonisolated static func healthspanDayKey(_ index: Int, _ fmt: DateFormatter) -> String { + fmt.string(from: Date(timeIntervalSince1970: TimeInterval(index) * 86_400)) + } + + /// Per-day zone doses from the two stored heart-rate-reserve series, keyed by day index. A day with + /// either series present is observed; the other side of a pair defaults to 0, since the day scan + /// writes both or neither. + nonisolated static func healthspanZones(moderate: [MetricPoint], vigorous: [MetricPoint]) + -> [Int: (moderate: Double, vigorous: Double)] { + var out: [Int: (moderate: Double, vigorous: Double)] = [:] + for p in moderate { if let i = PaceOfAgingEngine.dayIndex(p.day) { out[i] = (p.value, out[i]?.vigorous ?? 0) } } + for p in vigorous { if let i = PaceOfAgingEngine.dayIndex(p.day) { out[i] = (out[i]?.moderate ?? 0, p.value) } } + return out + } + + /// `(day, value)` points as a day-index dictionary, the last value winning on a duplicate day. + nonisolated static func healthspanByIndex(_ points: [(day: String, value: Double)]) -> [Int: Double] { + var out: [Int: Double] = [:] + for p in points { if let i = PaceOfAgingEngine.dayIndex(p.day) { out[i] = p.value } } + return out + } + + /// Load the history the resolver needs and write the Healthspan points for this device. + /// + /// `fresh` carries this pass's just-scored days and zone/strength minutes, which win over their + /// persisted copies (the persist of this pass may not be readable yet on every path). Re-derives the + /// whole history once when the stored model marker is older than `HealthspanSeries.modelVersion`; + /// otherwise refreshes the last `HealthspanSeries.refreshDays`. + static func recomputeHealthspan(store: WhoopStore, repo: Repository, computedId: String, + newestDay: String, age: Double, sex: String, profileWeightKg: Double?, + freshDays: [DailyMetric], + freshZones: [String: (moderate: Double, vigorous: Double)], + freshStrength: [String: Double], + tzOffset: Int) async { + guard let newest = PaceOfAgingEngine.dayIndex(newestDay) else { return } + let marker = (try? await store.metricSeries(deviceId: computedId, key: HealthspanSeries.modelKey, + from: HealthspanSeries.modelDay, + to: HealthspanSeries.modelDay))?.last?.value ?? 0 + let rederive = marker < HealthspanSeries.modelVersion + let outputDays = rederive ? HealthspanSeries.rederiveDays : HealthspanSeries.refreshDays + // The oldest window read is the six-month baseline of the oldest output day, plus the evening + // before its first night (the regularity pair). + let readDays = outputDays + PaceOfAgingEngine.baselineWindowDays + 1 + let fmt = healthspanDayFormatter() + let fromKey = Self.healthspanDayKey(newest - readDays, fmt) + + var history = HealthspanHistory() + for d in await repo.dailyMetrics(fromDay: fromKey, toDay: newestDay) { + if let i = PaceOfAgingEngine.dayIndex(d.day) { history.days[i] = d } + } + for d in freshDays { if let i = PaceOfAgingEngine.dayIndex(d.day) { history.days[i] = d } } + + let storedModerate = (try? await store.metricSeries(deviceId: computedId, key: HealthspanSeries.zoneModerate, + from: fromKey, to: newestDay)) ?? [] + let storedVigorous = (try? await store.metricSeries(deviceId: computedId, key: HealthspanSeries.zoneVigorous, + from: fromKey, to: newestDay)) ?? [] + history.zones = Self.healthspanZones(moderate: storedModerate, vigorous: storedVigorous) + for (day, dose) in freshZones { if let i = PaceOfAgingEngine.dayIndex(day) { history.zones[i] = dose } } + + let storedStrength = (try? await store.metricSeries(deviceId: computedId, key: HealthspanSeries.strength, + from: fromKey, to: newestDay)) ?? [] + history.strength = Self.healthspanByIndex(storedStrength.map { ($0.day, $0.value) }) + for (day, minutes) in freshStrength { if let i = PaceOfAgingEngine.dayIndex(day) { history.strength[i] = minutes } } + + // VO₂max: the strap's own weekly estimate, or an imported reading, which is independent evidence. + for (i, v) in Self.healthspanByIndex(await repo.exploreSeries(key: "vo2max_est", source: "my-whoop", + days: readDays + 2)) { + history.vo2max[i] = (v, .strap) + } + for (i, v) in Self.healthspanByIndex(await repo.exploreSeries(key: "vo2max", source: "apple-health", + days: readDays + 2)) where v > 0 { + history.vo2max[i] = (v, .external) + } + history.leanMass = Self.healthspanByIndex(await repo.exploreSeries(key: "lean_mass", source: "apple-health", + days: readDays + 2)).filter { $0.value > 0 } + history.weight = Self.healthspanByIndex(await repo.exploreSeries(key: "weight", source: "apple-health", + days: readDays + 2)).filter { $0.value > 0 } + + let sessions = await repo.allSleepSessions(days: readDays + 2).compactMap { + SleepRegularity.session(startTs: $0.effectiveStartTs, endTs: $0.endTs, stagesJSON: $0.stagesJSON) + } + let profileWeight = profileWeightKg + let first = newest - outputDays + 1 + // The heavy part — SRI epochs and one engine run per window per day — off the main actor. + let points = await Task.detached(priority: .utility) { [history] in + var h = history + h.sriAgreement = SleepRegularity.dailyAgreement(sessions: sessions, tzOffsetSec: tzOffset) + return Self.healthspanPoints(history: h, fromDay: first, toDay: newest, + age: age, sex: sex, profileWeightKg: profileWeight) + }.value + + let fromOut = rederive ? "0000-01-01" : Self.healthspanDayKey(first, fmt) + // A re-derivation also clears the model-1 rows: they were keyed to Saturdays, so the new daily + // points would not overwrite all of them, and a stale Saturday would splice the old model back in. + let written = (try? await store.replaceMetricSeries(points, deviceId: computedId, + keys: HealthspanSeries.allKeys, + from: fromOut, to: "9999-12-31")) != nil + // Marked only once the rows landed: a failed write leaves the marker old, so the next pass retries. + if rederive, written { + _ = try? await store.upsertMetricSeries( + [MetricPoint(day: HealthspanSeries.modelDay, key: HealthspanSeries.modelKey, + value: HealthspanSeries.modelVersion)], deviceId: computedId) + } + } +} diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index d703ab0b98..517dfe0259 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -240,6 +240,10 @@ final class IntelligenceEngine: ObservableObject { /// person's activity, not the part they remembered to label as a session. Empty when the day /// banked no heart rate. let zoneMinutes: [Double] + /// Healthspan: whole-day minutes in the five heart-rate-RESERVE zones (Karvonen, from this night's + /// resting HR and the profile's max HR) — the unit the Healthspan zone targets are stated in. Same + /// `dayHr` stream as `zoneMinutes`. Empty when the day banked no heart rate or has no resting HR. + let zoneMinutesHRR: [Double] /// #1118: whether this night's in-sleep R-R is OVER-COUNTED (`crossSecondOverCount` / /// `sameSecondOverCount`) — the WHOOP-4.0 two-optical-channel artifact that inflates R-R and /// contaminates the displayed HRV. nil when the night has no in-sleep R-R (no HRV to caveat). @@ -520,17 +524,6 @@ final class IntelligenceEngine: ObservableObject { return fmt.string(from: sat) } - /// Scored nights (a resting HR) a week needs before a weekly Vitality / Body Age is written. Matches - /// Fitness Age's `FitnessAgeEngine.minCoverageDays`. `VitalityEngine.minFactors` counts FACTORS, and one - /// night alone supplies four of them (resting HR, sleep, regularity, HRV), so without a night count a - /// single partial first day became a whole week's Body Age. - nonisolated static let healthspanMinWeekNights = FitnessAgeEngine.minCoverageDays - - /// Whether `days` (the week's rows) carry enough scored nights for a weekly Vitality / Body Age. - nonisolated static func healthspanWeekIsScorable(_ days: C) -> Bool where C.Element == DailyMetric { - days.filter { $0.restingHr != nil }.count >= healthspanMinWeekNights - } - /// Days in the persisted window whose `sleep_performance` point was not produced by this pass, i.e. /// the day no longer has a scored night. A point written by an earlier pass for a session that was /// later dropped (a short evening block re-detected away) would otherwise outlive the sleep it @@ -541,116 +534,9 @@ final class IntelligenceEngine: ObservableObject { return persisted.map { $0.day }.filter { !producedDays.contains($0) }.sorted() } - // MARK: - Healthspan (Body Age + Pace of Aging) inputs + // MARK: - Healthspan // - // One resolver builds the Vitality inputs for EVERY window — this week's headline and each rolling - // window the pace is fitted through. Two builders would let the Body Age and the pace that predicts it - // be computed from differently-shaped people. - - /// Observed days out of a window before an activity dose is claimed at all. Below this, one logged day - /// would become a whole week's dose. - static let healthspanMinActivityDays = 4 - - /// Per-day moderate (zones 2–3) and vigorous (zones 4–5) minutes from the raw five-zone split. - static func healthspanZoneDose(_ zoneMinutesByDay: [String: [Double]]) - -> [String: (moderate: Double, vigorous: Double)] { - var out: [String: (moderate: Double, vigorous: Double)] = [:] - for (day, zones) in zoneMinutesByDay where zones.count >= 5 { - out[day] = (moderate: zones[1] + zones[2], vigorous: zones[3] + zones[4]) - } - return out - } - - /// Scale an activity dose observed on `observedDays` of a `windowDays` window up to the whole window. - /// - /// A week rarely has seven observed days, and the published references are stated per week — so the - /// observed days are averaged and scaled, the same treatment the sleep and step means already get. - /// Nil below `healthspanMinActivityDays`, because scaling one day by seven is not a measurement. - static func healthspanScaledDose(total: Double, observedDays: Int, windowDays: Int) -> Double? { - guard observedDays >= healthspanMinActivityDays, observedDays > 0, windowDays > 0 else { return nil } - return total / Double(observedDays) * Double(windowDays) - } - - /// Build the Vitality inputs for one window of days. - /// - /// A day counts as OBSERVED for activity when it banked a zone reading, i.e. the strap saw its heart - /// rate. On such a day, no strength entry means genuinely no strength training — absent and zero are - /// different facts, and only a day we watched can distinguish them. - static func healthspanInputs(days: [DailyMetric], - zone: [String: (moderate: Double, vigorous: Double)], - strength: [String: Double], - age: Double, sex: String, - weightKg: Double?, leanMassKg: Double?) -> VitalityEngine.Inputs { - let rhrs = days.compactMap { $0.restingHr }.map(Double.init) - let nights = days.compactMap { $0.totalSleepMin }.map { Double($0) / 60.0 }.filter { $0 > 0 } - let steps = days.compactMap { $0.steps }.map(Double.init) - let observed = days.map { $0.day }.filter { zone[$0] != nil } - let moderate = observed.reduce(0.0) { $0 + (zone[$1]?.moderate ?? 0) } - let vigorous = observed.reduce(0.0) { $0 + (zone[$1]?.vigorous ?? 0) } - let strengthTotal = observed.reduce(0.0) { $0 + (strength[$1] ?? 0) } - // Doses are stated per WEEK whatever the window is, so a 30-day window is scaled to seven days. - return VitalityEngine.Inputs( - chronoAge: age, - sex: sex, - restingHR: rhrs.isEmpty ? nil : IntelligenceEngine.medianOf(rhrs), - sleepHours: nights.isEmpty ? nil : nights.reduce(0, +) / Double(nights.count), - steps: steps.isEmpty ? nil : steps.reduce(0, +) / Double(steps.count), - moderateMinPerWeek: healthspanScaledDose(total: moderate, observedDays: observed.count, - windowDays: 7), - vigorousMinPerWeek: healthspanScaledDose(total: vigorous, observedDays: observed.count, - windowDays: 7), - strengthMinPerWeek: healthspanScaledDose(total: strengthTotal, observedDays: observed.count, - windowDays: 7), - leanMassKg: leanMassKg, - weightKg: weightKg) - } - - /// One rolling-30-day Vitality sample per day across the pace engine's trend window. - /// - /// The factor signature carried on each sample is the sorted set of contribution keys behind it, so the - /// engine can drop a stretch where the factor set differed rather than reading "you connected a step - /// source" as "you aged". - static func healthspanPaceSamples(days: [DailyMetric], - zone: [String: (moderate: Double, vigorous: Double)], - strength: [String: Double], - age: Double, sex: String, - weightKg: Double?, leanMassKg: Double?) - -> [PaceOfAgingEngine.Sample] { - let sorted = days.sorted { $0.day < $1.day } - guard let newest = sorted.last, let newestIndex = PaceOfAgingEngine.dayIndex(newest.day) else { - return [] - } - var byIndex: [Int: DailyMetric] = [:] - for d in sorted { if let i = PaceOfAgingEngine.dayIndex(d.day) { byIndex[i] = d } } - - var out: [PaceOfAgingEngine.Sample] = [] - for offset in 0..= PaceOfAgingEngine.minWindowDays else { continue } - let inputs = healthspanInputs(days: window, zone: zone, strength: strength, - age: age, sex: sex, weightKg: weightKg, leanMassKg: leanMassKg) - guard let result = VitalityEngine.compute(inputs) else { continue } - out.append(PaceOfAgingEngine.Sample( - dayIndex: end, lnHazardSum: result.lnHazardSum, - factorSignature: result.contributions.map { $0.key }.sorted().joined(separator: ","))) - } - return out.sorted { $0.dayIndex < $1.dayIndex } - } - - /// The most recent imported lean-mass reading (kg), or nil when none exists. - /// - /// Read-only, from whatever Apple Health / body-composition import already banked it — NOOP measures - /// no body composition of its own, and the Healthspan lean-mass driver stays absent without one. - static func latestLeanMassKg(repo: Repository) async -> Double? { - let series = await repo.exploreSeries(key: "lean_mass", source: "apple-health") - guard let latest = series.last?.value, latest > 0 else { return nil } - return latest - } + // The resolver, the windows and the persisted points live in `HealthspanPipeline.swift`. /// The "yyyy-MM-dd" key `count` days before `before`. Falls back to `before` on an unparseable key. static func dayKey(daysBefore count: Int, before: String) -> String { @@ -1804,6 +1690,11 @@ final class IntelligenceEngine: ObservableObject { // stated in minutes per week, so minutes is the unit that leaves this loop. let zoneMinutes = dayHr.isEmpty ? [] : HRZones.timeInZone(dayHr, zoneSet: healthspanZoneSet).seconds.map { $0 / 60.0 } + let hrrZoneSet = res.daily.restingHr.flatMap { + HRZones.heartRateReserveZones(restingHR: Double($0), maxHR: healthspanZoneSet.maxHR) + } + let zoneMinutesHRR = dayHr.isEmpty ? [] + : hrrZoneSet.map { HRZones.timeInZone(dayHr, zoneSet: $0).seconds.map { $0 / 60.0 } } ?? [] let scan = DayScan(result: res, rhrLine: rhrLine, rhrBinLine: rhrBinLine, respLine: respLine, readOwner: owner, hrRows: hr.count, @@ -1811,6 +1702,7 @@ final class IntelligenceEngine: ObservableObject { hrvDiag: Self.mergedDayDiag(hrvDiag, strainDiagLines), spo2Candidate: spo2CandidateNight, zoneMinutes: zoneMinutes, + zoneMinutesHRR: zoneMinutesHRR, hrvOverCounted: hrvOverCounted, primarySessionRHR: primarySessionRHR, primarySessionRHRCoverage: primarySessionRHRCoverage) @@ -1908,6 +1800,7 @@ final class IntelligenceEngine: ObservableObject { var primarySessionRHRCoverageByDay: [String: PrimarySessionRestingHR.Coverage] = [:] // Healthspan: per-day zone minutes carried from pass 1 for metricSeries persistence. var zoneMinutesByDay: [String: [Double]] = [:] + var zoneMinutesHRRByDay: [String: [Double]] = [:] // Back on the main actor: fold the off-actor results into the pass-2 state in the SAME order the // loop produced them. Pure assignment / appends , no further store reads , so this is cheap and the @@ -1940,6 +1833,9 @@ final class IntelligenceEngine: ObservableObject { if !scan.zoneMinutes.isEmpty { zoneMinutesByDay[res.daily.day] = scan.zoneMinutes } + if !scan.zoneMinutesHRR.isEmpty { + zoneMinutesHRRByDay[res.daily.day] = scan.zoneMinutesHRR + } if let line = scan.rhrLine { diagnosticSink?(line, nil) } if let line = scan.rhrBinLine { diagnosticSink?(line, nil) } if let line = scan.respLine { diagnosticSink?(line, nil) } @@ -2403,6 +2299,16 @@ final class IntelligenceEngine: ObservableObject { restPoints.append(MetricPoint(day: daily.day, key: "zone_min_4_5", value: zones[3] + zones[4])) } + // The heart-rate-RESERVE pair the Healthspan drivers read: zones 1–3 (50–80 % HRR) and 4–5 + // (≥ 80 % HRR), the model WHOOP's zone targets are stated in. Unlike the %HRmax pair above, + // zone 1 belongs here: 50 % of the RESERVE is already a moderate effort. 0 written rather than + // omitted on a watched day, for the same absent-versus-none reason. + if let zones = zoneMinutesHRRByDay[daily.day], zones.count >= 5 { + restPoints.append(MetricPoint(day: daily.day, key: HealthspanSeries.zoneModerate, + value: zones[0] + zones[1] + zones[2])) + restPoints.append(MetricPoint(day: daily.day, key: HealthspanSeries.zoneVigorous, + value: zones[3] + zones[4])) + } // Muscle-strengthening minutes for the day, from the workout rows already read for the // detected-bout dedup. Lift Log sessions are NOT summed separately: a logged session always // carries a paired workout row at the same instant (`LiftSessionRow.startTs`), so adding both @@ -2636,15 +2542,12 @@ final class IntelligenceEngine: ObservableObject { // optional VO₂max when a waist is set) under the same "-noop" source. Idempotent on the Saturday // key, so the number refines through the week and finalises on Saturday. Engine = FitnessAgeEngine // (StrandAnalytics), fully unit-tested; the body term cancels so the headline needs no body metric. - let fa7 = dailies.sorted { $0.day < $1.day }.suffix(7) - let faRHRs = fa7.compactMap { $0.restingHr }.map(Double.init) // The Fitness Age gate + compute read the PERSISTED/MERGED last-7 days , the SAME history the // readiness card + dashboard show , NOT this pass's freshly scored `dailies`. A recompute only // re-scores nights whose raw HR still lives in the store, so a nightly wearer whose card reads // "7 of 7 nights" could still leave the engine seeing <4 RHR nights on `dailies`, and Fitness Age // never computed (Vitality did , it needs only 3 of ANY input, which is why Body Age showed but - // Fitness Age did not). Kept SEPARATE from `fa7` so Vitality (below), which already computes, is - // untouched. Gate on the UNION of the pre-rewrite persisted history and THIS pass's fresh scores + // Fitness Age did not). Gate on the UNION of the pre-rewrite persisted history and THIS pass's fresh scores // (by day, fresh wins), so an RHR night counts whether it survives in the store, was just scored, // or came from an import. The gate + compute live in `fitnessAgeRows`, shared with the manual // "refresh Fitness Age" button so the two can never drift. @@ -2664,68 +2567,19 @@ final class IntelligenceEngine: ObservableObject { ) } - // ── Vitality / Body Age (Phase 7) , weekly, keyed to the week's Saturday ──────────────────── - // Roll the last 7 days' wearable signals into the mortality-hazard model and upsert a weekly - // Vitality (0–100) + Body Age. VitalityEngine gates on ≥3 inputs, so a sparse week writes nothing. - // (VO₂max is omitted here , fitness is already its own Fitness Age headline; Vitality leans on - // resting HR, sleep duration + regularity, HRV-vs-age-norm, and steps.) - // Vitality and the pace of aging are built from the SAME input resolver - // (`healthspanInputs`) over different windows — this week for the headline, one rolling 30-day - // window per day for the trend. Two builders would let the two numbers describe different people. - let vLeanMass = await Self.latestLeanMassKg(repo: repo) - let vZone = Self.healthspanZoneDose(zoneMinutesByDay) - let vInputs = Self.healthspanInputs( - days: Array(fa7), zone: vZone, strength: strengthMinutesByDay, - age: Double(profile.age), sex: profile.sex, - weightKg: profile.weightKg > 0 ? profile.weightKg : nil, leanMassKg: vLeanMass) - if Self.healthspanWeekIsScorable(fa7), let vRes = VitalityEngine.compute(vInputs) { - let satKey = IntelligenceEngine.saturdayKey(onOrBefore: newestDay) - _ = try? await store.upsertMetricSeries([ - MetricPoint(day: satKey, key: "vitality", value: vRes.vitality), - MetricPoint(day: satKey, key: "body_age", value: vRes.bodyAge), - ], deviceId: computedId) - } - - // ── Pace of Aging , weekly, keyed to the same Saturday ────────────────────────────────────── - // Body Age answers "where am I"; the pace answers "where am I heading", which one weekly number - // cannot. `PaceOfAgingEngine` fits the drift of the SAME summed log-hazard the Body Age comes from, - // so the two cannot tell different stories: a pace above 1 IS a Body Age on its way up. - // - // It needs months rather than a week, so it reads the persisted history instead of this pass's - // 21-day scan — the daily rows plus the three activity series this pass has been writing. The - // freshly-computed days win over their persisted copies where both exist. - let paceFromDay = Self.dayKey(daysBefore: PaceOfAgingEngine.recentWindowDays - + PaceOfAgingEngine.trendWindowDays + 2, before: newestDay) - let paceDaily = await repo.dailyMetrics(fromDay: paceFromDay, toDay: newestDay) - var paceZone: [String: (moderate: Double, vigorous: Double)] = [:] - let storedModerate = (try? await store.metricSeries(deviceId: computedId, key: "zone_min_2_3", - from: paceFromDay, to: newestDay)) ?? [] - let storedVigorous = (try? await store.metricSeries(deviceId: computedId, key: "zone_min_4_5", - from: paceFromDay, to: newestDay)) ?? [] - let vigorousByDay = Dictionary(storedVigorous.map { ($0.day, $0.value) }, uniquingKeysWith: { _, b in b }) - for point in storedModerate { - paceZone[point.day] = (moderate: point.value, vigorous: vigorousByDay[point.day] ?? 0) - } - for (day, dose) in vZone { paceZone[day] = dose } // this pass's fresh scores win - var paceStrength = Dictionary( - ((try? await store.metricSeries(deviceId: computedId, key: "strength_min", - from: paceFromDay, to: newestDay)) ?? []) - .map { ($0.day, $0.value) }, uniquingKeysWith: { _, b in b }) - for (day, minutes) in strengthMinutesByDay { paceStrength[day] = minutes } - let paceSamples = Self.healthspanPaceSamples( - days: paceDaily, zone: paceZone, strength: paceStrength, - age: Double(profile.age), sex: profile.sex, - weightKg: profile.weightKg > 0 ? profile.weightKg : nil, leanMassKg: vLeanMass) - if let pace = PaceOfAgingEngine.compute(samples: paceSamples) { - let satKey = IntelligenceEngine.saturdayKey(onOrBefore: newestDay) - _ = try? await store.upsertMetricSeries([ - MetricPoint(day: satKey, key: "pace_of_aging", value: pace.pace), - // The margin travels WITH the pace, keyed to the same day. A pace read back without it - // cannot tell a real trend from a fit through noise, and a dial that cannot tell the - // difference will state a direction the data does not support. - MetricPoint(day: satKey, key: "pace_of_aging_margin", value: pace.paceMargin), - ], deviceId: computedId) + // ── Healthspan (Body Age + Pace of Aging) , one point per day ──────────────────────────────── + // Six-month Body Age, the 30-day pace projection and each driver's years, from the one resolver in + // HealthspanPipeline.swift. This pass's fresh %HRR zone minutes and strength minutes win over their + // persisted copies; a model upgrade re-derives the stored history once. + var hsZones: [String: (moderate: Double, vigorous: Double)] = [:] + for (day, zones) in zoneMinutesHRRByDay where zones.count >= 5 { + hsZones[day] = (moderate: zones[0] + zones[1] + zones[2], vigorous: zones[3] + zones[4]) } + await Self.recomputeHealthspan(store: store, repo: repo, computedId: computedId, newestDay: newestDay, + age: Double(profile.age), sex: profile.sex, + profileWeightKg: profile.weightKg > 0 ? profile.weightKg : nil, + freshDays: dailies, freshZones: hsZones, + freshStrength: strengthMinutesByDay, tzOffset: tzOffset) markPostLoopPhase("weekly") // ── Steps ESTIMATE (WHOOP 4.0) , DAILY, keyed to each strap-only day ──────────────────────── diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 5badb94372..8f92787c2c 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -249760,6 +249760,1920 @@ } } } + }, + "Zones 1–3 cardio": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Cardio Zonen 1–3" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cardio zonas 1–3" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cardio zones 1–3" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cardio zone 1–3" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Cardio strefy 1–3" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Cardio zonas 1–3" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кардио зоны 1–3" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1–3 区有氧" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1–3 區有氧" + } + } + } + }, + "Zones 4–5 cardio": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Cardio Zonen 4–5" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cardio zonas 4–5" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cardio zones 4–5" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cardio zone 4–5" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Cardio strefy 4–5" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Cardio zonas 4–5" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кардио зоны 4–5" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "4–5 区有氧" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "4–5 區有氧" + } + } + } + }, + "Healthspan is for adults 18 and over.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Healthspan ist für Erwachsene ab 18 Jahren." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Healthspan es para adultos de 18 años o más." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Healthspan s’adresse aux adultes de 18 ans et plus." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Healthspan è per adulti dai 18 anni in su." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Healthspan jest dla dorosłych od 18 lat." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O Healthspan destina-se a adultos a partir dos 18 anos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Healthspan предназначен для взрослых от 18 лет." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Healthspan 适用于 18 岁及以上的成年人。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Healthspan 適用於 18 歲以上的成年人。" + } + } + } + }, + "%lld of %lld scored days in the last 31": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld von %lld bewerteten Tagen in den letzten 31" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld de %lld días puntuados en los últimos 31" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld sur %lld jours évalués sur les 31 derniers" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld di %lld giorni valutati negli ultimi 31" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%lld z %lld ocenionych dni w ostatnich 31" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "%lld de %lld dias avaliados nos últimos 31" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld из %lld оценённых дней за последние 31" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近 31 天中已评分 %lld/%lld 天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近 31 天中已評分 %lld/%lld 天" + } + } + } + }, + "Body Age compares your last six months with health targets. Keep wearing the strap overnight.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Körperalter vergleicht deine letzten sechs Monate mit Gesundheitszielen. Trag das Band weiter über Nacht." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La edad corporal compara tus últimos seis meses con objetivos de salud. Sigue llevando la pulsera por la noche." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’âge corporel compare vos six derniers mois à des objectifs de santé. Continuez à porter le bracelet la nuit." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L’età corporea confronta gli ultimi sei mesi con obiettivi di salute. Continua a indossare la fascia di notte." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wiek ciała porównuje ostatnie sześć miesięcy z celami zdrowotnymi. Noś opaskę dalej w nocy." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A idade corporal compara os teus últimos seis meses com metas de saúde. Continua a usar a pulseira durante a noite." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Возраст тела сравнивает ваши последние шесть месяцев с целями здоровья. Продолжайте носить браслет ночью." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "身体年龄将你最近六个月与健康目标对比。请继续在夜间佩戴手环。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "身體年齡將你最近六個月與健康目標對比。請繼續在夜間佩戴手環。" + } + } + } + }, + "± %@ · if your last 30 days hold for six months": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "± %@ · wenn deine letzten 30 Tage sechs Monate lang so bleiben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "± %@ · si tus últimos 30 días se mantienen seis meses" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "± %@ · si vos 30 derniers jours se maintiennent six mois" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "± %@ · se gli ultimi 30 giorni restano così per sei mesi" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "± %@ · jeśli ostatnie 30 dni utrzyma się przez sześć miesięcy" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "± %@ · se os teus últimos 30 dias se mantiverem por seis meses" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "± %@ · если последние 30 дней сохранятся шесть месяцев" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "± %@ · 若最近 30 天的状态保持六个月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "± %@ · 若最近 30 天的狀態維持六個月" + } + } + } + }, + "Projected Body Age in six months: %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Voraussichtliches Körperalter in sechs Monaten: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Edad corporal prevista en seis meses: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Âge corporel projeté dans six mois : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Età corporea prevista tra sei mesi: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przewidywany wiek ciała za sześć miesięcy: %@" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Idade corporal prevista daqui a seis meses: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прогноз возраста тела через шесть месяцев: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "六个月后的预计身体年龄:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "六個月後的預計身體年齡:%@" + } + } + } + }, + "The pace compares your last 30 days with your last six months, so it appears once both can be scored.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Tempo vergleicht deine letzten 30 Tage mit deinen letzten sechs Monaten und erscheint, sobald beide bewertet werden können." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El ritmo compara tus últimos 30 días con tus últimos seis meses, así que aparece cuando ambos se pueden puntuar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le rythme compare vos 30 derniers jours à vos six derniers mois ; il apparaît dès que les deux peuvent être évalués." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il ritmo confronta gli ultimi 30 giorni con gli ultimi sei mesi, quindi compare quando entrambi si possono valutare." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tempo porównuje ostatnie 30 dni z ostatnimi sześcioma miesiącami, więc pojawi się, gdy oba okresy da się ocenić." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O ritmo compara os teus últimos 30 dias com os últimos seis meses, por isso aparece quando ambos puderem ser avaliados." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Темп сравнивает последние 30 дней с последними шестью месяцами, поэтому появится, когда оба периода можно будет оценить." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "速度将你最近 30 天与最近六个月对比,因此两者都可评分后才会显示。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "速度將你最近 30 天與最近六個月對比,因此兩者都可評分後才會顯示。" + } + } + } + }, + "Reversing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Umkehrend" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Revirtiendo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En inversion" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In inversione" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odwracanie" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A reverter" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обратный ход" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "逆转中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "逆轉中" + } + } + } + }, + "Slowing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verlangsamt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desacelerando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En ralentissement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In rallentamento" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spowalnianie" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A abrandar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Замедление" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "放缓" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "放緩" + } + } + } + }, + "Accelerating": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beschleunigt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acelerando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En accélération" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "In accelerazione" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przyspieszanie" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A acelerar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ускорение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加速" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加速" + } + } + } + }, + "Biggest lever": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Größter Hebel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mayor palanca" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Plus grand levier" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Leva principale" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Największa dźwignia" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Maior alavanca" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Главный рычаг" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最大杠杆" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最大槓桿" + } + } + } + }, + "Reaching your target for %@ would take about %@ years off your Body Age.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn du dein Ziel für %@ erreichst, sinkt dein Körperalter um etwa %@ Jahre." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alcanzar tu objetivo de %@ restaría unos %@ años a tu edad corporal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Atteindre votre objectif pour %@ retirerait environ %@ ans à votre âge corporel." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Raggiungere il tuo obiettivo per %@ toglierebbe circa %@ anni alla tua età corporea." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Osiągnięcie celu dla %@ odjęłoby około %@ lat od wieku ciała." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Atingir a tua meta de %@ tiraria cerca de %@ anos à tua idade corporal." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Достижение цели по %@ снизит возраст тела примерно на %@ лет." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "达到%@的目标可让身体年龄减少约 %@ 岁。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "達到%@的目標可讓身體年齡減少約 %@ 歲。" + } + } + } + }, + "Every driver is on target. Nothing is costing you years.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Faktoren sind im Ziel. Nichts kostet dich Jahre." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todos los factores están en su objetivo. Nada te está costando años." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tous les facteurs sont dans l’objectif. Rien ne vous coûte d’années." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tutti i fattori sono sull’obiettivo. Niente ti costa anni." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wszystkie czynniki są w celu. Nic nie kosztuje cię lat." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Todos os fatores estão na meta. Nada te está a custar anos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все факторы в пределах цели. Ничто не прибавляет вам лет." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有因素均达标,没有任何因素让你变老。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有因素均達標,沒有任何因素讓你變老。" + } + } + } + }, + "Last 30 days: better": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzte 30 Tage: besser" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Últimos 30 días: mejor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "30 derniers jours : mieux" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ultimi 30 giorni: meglio" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ostatnie 30 dni: lepiej" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Últimos 30 dias: melhor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последние 30 дней: лучше" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近 30 天:更好" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近 30 天:更好" + } + } + } + }, + "Last 30 days: worse": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzte 30 Tage: schlechter" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Últimos 30 días: peor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "30 derniers jours : moins bien" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ultimi 30 giorni: peggio" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ostatnie 30 dni: gorzej" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Últimos 30 dias: pior" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последние 30 дней: хуже" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近 30 天:更差" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近 30 天:更差" + } + } + } + }, + "Risk against your target": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Risiko gegenüber deinem Ziel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Riesgo frente a tu objetivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Risque par rapport à votre objectif" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rischio rispetto al tuo obiettivo" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ryzyko względem celu" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Risco face à tua meta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Риск относительно цели" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相对目标的风险" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "相對目標的風險" + } + } + } + }, + "1.0 is your target. Above the line costs years; below it helps.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "1,0 ist dein Ziel. Oberhalb der Linie kostet es Jahre, darunter hilft es." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1,0 es tu objetivo. Por encima de la línea cuesta años; por debajo ayuda." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1,0 correspond à votre objectif. Au-dessus de la ligne, cela coûte des années ; en dessous, cela aide." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "1,0 è il tuo obiettivo. Sopra la linea costa anni; sotto aiuta." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "1,0 to twój cel. Powyżej linii kosztuje lata, poniżej pomaga." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "1,0 é a tua meta. Acima da linha custa anos; abaixo ajuda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1,0 — ваша цель. Выше линии прибавляет годы, ниже помогает." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1.0 为你的目标。高于该线会增加年龄,低于则有帮助。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1.0 為你的目標。高於該線會增加年齡,低於則有幫助。" + } + } + } + }, + "Hazard ratio curve for %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hazard-Ratio-Kurve für %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Curva de razón de riesgo de %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Courbe du rapport de risque pour %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Curva del rapporto di rischio per %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Krzywa współczynnika ryzyka dla %@" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Curva da razão de risco para %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кривая отношения рисков для %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@的风险比曲线" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@的風險比曲線" + } + } + } + }, + "Body Age over six months": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Körperalter über sechs Monate" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Edad corporal en seis meses" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Âge corporel sur six mois" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Età corporea negli ultimi sei mesi" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wiek ciała w ciągu sześciu miesięcy" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Idade corporal ao longo de seis meses" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Возраст тела за шесть месяцев" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "六个月身体年龄" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "六個月身體年齡" + } + } + } + }, + "Pace of Aging over six months": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alterungstempo über sechs Monate" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ritmo de envejecimiento en seis meses" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rythme de vieillissement sur six mois" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ritmo di invecchiamento negli ultimi sei mesi" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Tempo starzenia w ciągu sześciu miesięcy" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ritmo de envelhecimento ao longo de seis meses" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Темп старения за шесть месяцев" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "六个月衰老速度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "六個月老化速度" + } + } + } + }, + "Context": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kontext" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contexto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contexte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Contesto" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kontekst" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Contexto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Контекст" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "参考" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "參考" + } + } + } + }, + "Not scored": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht bewertet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin puntuar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non évalué" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non valutato" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieoceniane" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Não avaliado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не оценивается" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不计分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不計分" + } + } + } + }, + "%@ ms over the last 30 days · typical for your age %@ ms": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ms in den letzten 30 Tagen · typisch für dein Alter %@ ms" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ ms en los últimos 30 días · típico para tu edad %@ ms" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ ms sur les 30 derniers jours · typique pour votre âge %@ ms" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ ms negli ultimi 30 giorni · tipico per la tua età %@ ms" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ ms w ostatnich 30 dniach · typowo dla twojego wieku %@ ms" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "%@ ms nos últimos 30 dias · típico para a tua idade %@ ms" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ мс за последние 30 дней · типично для вашего возраста %@ мс" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最近 30 天 %@ 毫秒 · 你这个年龄的典型值 %@ 毫秒" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近 30 天 %@ 毫秒 · 你這個年齡的典型值 %@ 毫秒" + } + } + } + }, + "Shown for context only. HRV is not a driver: its link to lifespan does not hold up as cause and effect.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur als Kontext angezeigt. HRV ist kein Faktor: Ihr Zusammenhang mit der Lebensdauer hält als Ursache und Wirkung nicht stand." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se muestra solo como contexto. La VFC no es un factor: su relación con la longevidad no se sostiene como causa y efecto." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiché à titre indicatif uniquement. La VFC n’est pas un facteur : son lien avec la longévité ne tient pas comme relation de cause à effet." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostrato solo come contesto. L’HRV non è un fattore: il suo legame con la longevità non regge come causa ed effetto." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazane tylko jako kontekst. HRV nie jest czynnikiem: jej związek z długością życia nie potwierdza się jako przyczyna i skutek." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Mostrado apenas como contexto. A VFC não é um fator: a sua ligação à longevidade não se confirma como causa e efeito." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показано только для контекста. ВСР не является фактором: её связь с продолжительностью жизни не подтверждается как причинно-следственная." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅作参考显示。HRV 不是计分因素:它与寿命的关联不能成立为因果关系。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅作參考顯示。HRV 不是計分因素:它與壽命的關聯不能成立為因果關係。" + } + } + } + }, + "Method and references": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Methode und Quellen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Método y referencias" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Méthode et références" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Metodo e riferimenti" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Metoda i źródła" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Método e referências" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Методика и источники" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "方法与参考文献" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "方法與參考文獻" + } + } + } + }, + "References": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Quellen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Referencias" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Références" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riferimenti" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Źródła" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Referências" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источники" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "参考文献" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "參考文獻" + } + } + } + }, + "%@ yrs": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ J." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ años" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ ans" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ anni" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ lat" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "%@ anos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ лет" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 岁" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 歲" + } + } + } + }, + "Each driver is compared with a health target, not with the average person. Meeting every target reads as your own age; the average adult reads several years older.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Jeder Faktor wird mit einem Gesundheitsziel verglichen, nicht mit dem Durchschnitt. Wer alle Ziele erreicht, liegt bei seinem eigenen Alter; ein durchschnittlicher Erwachsener liegt einige Jahre darüber." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cada factor se compara con un objetivo de salud, no con la persona media. Cumplir todos los objetivos equivale a tu propia edad; el adulto medio sale varios años mayor." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chaque facteur est comparé à un objectif de santé, et non à la moyenne. Atteindre tous les objectifs correspond à votre propre âge ; l’adulte moyen apparaît plusieurs années plus âgé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ogni fattore è confrontato con un obiettivo di salute, non con la persona media. Raggiungere tutti gli obiettivi equivale alla tua età; l’adulto medio risulta più vecchio di alcuni anni." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Każdy czynnik porównywany jest z celem zdrowotnym, a nie z przeciętną osobą. Spełnienie wszystkich celów daje twój własny wiek; przeciętny dorosły wypada kilka lat starzej." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Cada fator é comparado com uma meta de saúde, não com a pessoa média. Cumprir todas as metas corresponde à tua própria idade; o adulto médio fica vários anos acima." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каждый фактор сравнивается с целью здоровья, а не со средним человеком. Выполнение всех целей соответствует вашему возрасту; средний взрослый выглядит на несколько лет старше." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个因素都与健康目标比较,而不是与普通人比较。达到所有目标即等于你的实际年龄;普通成年人会显示年长数岁。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每個因素都與健康目標比較,而不是與一般人比較。達到所有目標即等於你的實際年齡;一般成年人會顯示年長數歲。" + } + } + } + }, + "Each driver's distance from its target is matched to a published all-cause-mortality hazard ratio and converted to years at 10 × ln(hazard ratio).": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Abstand jedes Faktors zu seinem Ziel wird einer veröffentlichten Hazard-Ratio für die Gesamtsterblichkeit zugeordnet und mit 10 × ln(Hazard-Ratio) in Jahre umgerechnet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La distancia de cada factor a su objetivo se asocia a una razón de riesgo de mortalidad por cualquier causa publicada y se convierte en años con 10 × ln(razón de riesgo)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’écart de chaque facteur à son objectif est associé à un rapport de risque de mortalité toutes causes publié, puis converti en années par 10 × ln(rapport de risque)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La distanza di ogni fattore dal suo obiettivo è associata a un rapporto di rischio di mortalità per tutte le cause pubblicato e convertita in anni con 10 × ln(rapporto di rischio)." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odległość każdego czynnika od celu jest przypisana do opublikowanego współczynnika ryzyka zgonu z dowolnej przyczyny i przeliczana na lata jako 10 × ln(współczynnik ryzyka)." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A distância de cada fator à sua meta é associada a uma razão de risco de mortalidade por todas as causas publicada e convertida em anos por 10 × ln(razão de risco)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отклонение каждого фактора от цели сопоставляется с опубликованным отношением рисков общей смертности и переводится в годы как 10 × ln(отношение рисков)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个因素与目标的差距会对应到已发表的全因死亡风险比,并按 10 × ln(风险比) 换算为年数。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每個因素與目標的差距會對應到已發表的全因死亡風險比,並按 10 × ln(風險比) 換算為年數。" + } + } + } + }, + "Drivers that measure the same thing are not counted twice: steps, cardio minutes and strength minutes are weighed against each other, and a strap-estimated VO₂max is folded into resting heart rate.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Faktoren, die dasselbe messen, zählen nicht doppelt: Schritte, Cardio- und Kraftminuten werden gegeneinander gewichtet, und ein vom Band geschätztes VO₂max wird mit der Ruheherzfrequenz zusammengefasst." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los factores que miden lo mismo no se cuentan dos veces: pasos, minutos de cardio y de fuerza se ponderan entre sí, y el VO₂máx estimado por la pulsera se combina con la frecuencia cardiaca en reposo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les facteurs qui mesurent la même chose ne comptent pas deux fois : pas, minutes de cardio et de renforcement sont pondérés entre eux, et une VO₂max estimée par le bracelet est fusionnée avec la fréquence cardiaque au repos." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I fattori che misurano la stessa cosa non si contano due volte: passi, minuti di cardio e di forza si bilanciano tra loro, e un VO₂max stimato dalla fascia è unito alla frequenza cardiaca a riposo." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Czynniki mierzące to samo nie są liczone podwójnie: kroki, minuty cardio i siłowe są ważone względem siebie, a VO₂max szacowane przez opaskę łączy się z tętnem spoczynkowym." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Fatores que medem o mesmo não contam duas vezes: passos, minutos de cardio e de força são ponderados entre si, e um VO₂máx estimado pela pulseira é combinado com a frequência cardíaca em repouso." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Факторы, измеряющие одно и то же, не учитываются дважды: шаги, минуты кардио и силовых взвешиваются друг против друга, а оценка VO₂max браслетом объединяется с пульсом покоя." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "衡量同一事物的因素不会重复计算:步数、有氧分钟与力量分钟相互加权,手环估算的最大摄氧量会与静息心率合并。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "衡量同一事物的因素不會重複計算:步數、有氧分鐘與力量分鐘相互加權,手環估算的最大攝氧量會與靜息心率合併。" + } + } + } + }, + "Body Age uses your last six months. Pace of Aging holds your last 30 days for six months: 1× is holding steady.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Körperalter nutzt deine letzten sechs Monate. Das Alterungstempo schreibt deine letzten 30 Tage sechs Monate fort: 1× bedeutet stabil." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La edad corporal usa tus últimos seis meses. El ritmo de envejecimiento mantiene tus últimos 30 días durante seis meses: 1× es mantenerse estable." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’âge corporel utilise vos six derniers mois. Le rythme de vieillissement prolonge vos 30 derniers jours sur six mois : 1× signifie stable." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L’età corporea usa gli ultimi sei mesi. Il ritmo di invecchiamento mantiene gli ultimi 30 giorni per sei mesi: 1× significa stabile." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wiek ciała wykorzystuje ostatnie sześć miesięcy. Tempo starzenia utrzymuje ostatnie 30 dni przez sześć miesięcy: 1× oznacza stabilnie." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A idade corporal usa os teus últimos seis meses. O ritmo de envelhecimento mantém os teus últimos 30 dias durante seis meses: 1× significa estável." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Возраст тела учитывает последние шесть месяцев. Темп старения продлевает последние 30 дней на шесть месяцев: 1× — без изменений." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "身体年龄使用你最近六个月的数据。衰老速度将最近 30 天延续六个月:1× 表示持平。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "身體年齡使用你最近六個月的資料。老化速度將最近 30 天延續六個月:1× 表示持平。" + } + } + } + }, + "The hazard ratios come from observational studies, and nothing here has been validated against lifespan. Treat it as a wellness trend, not a clinical biological age.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Hazard-Ratios stammen aus Beobachtungsstudien, und nichts davon wurde an der Lebensdauer validiert. Betrachte es als Wellness-Trend, nicht als klinisches biologisches Alter." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las razones de riesgo proceden de estudios observacionales y nada de esto se ha validado frente a la longevidad. Tómalo como una tendencia de bienestar, no como una edad biológica clínica." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les rapports de risque proviennent d’études observationnelles, et rien ici n’a été validé par rapport à la longévité. Considérez-le comme une tendance de bien-être, pas comme un âge biologique clinique." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I rapporti di rischio provengono da studi osservazionali e nulla di tutto ciò è stato convalidato rispetto alla longevità. Consideralo una tendenza di benessere, non un’età biologica clinica." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Współczynniki ryzyka pochodzą z badań obserwacyjnych i nic tutaj nie zostało zweryfikowane względem długości życia. Traktuj to jako trend samopoczucia, a nie kliniczny wiek biologiczny." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "As razões de risco vêm de estudos observacionais e nada aqui foi validado face à longevidade. Encara-o como uma tendência de bem-estar, não como uma idade biológica clínica." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отношения рисков взяты из наблюдательных исследований, и ничто здесь не проверено на продолжительности жизни. Воспринимайте это как тренд самочувствия, а не клинический биологический возраст." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "风险比来自观察性研究,这里的内容都未经寿命验证。请将其视为健康趋势,而非临床生物学年龄。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "風險比來自觀察性研究,這裡的內容都未經壽命驗證。請將其視為健康趨勢,而非臨床生物學年齡。" + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/HealthView.swift b/Strand/Screens/HealthView.swift index b5e8d18cc7..fe38bbeb73 100644 --- a/Strand/Screens/HealthView.swift +++ b/Strand/Screens/HealthView.swift @@ -1193,10 +1193,10 @@ private struct ReadinessChecklistCard: View { // MARK: - Vitality / Body Age -/// The "Vitality" section: a weekly wellness score (0–100) + a Body Age in years, computed by +/// The "Vitality" section: a wellness score (0–100) + a six-month Body Age in years, computed by /// IntelligenceEngine from the published mortality-hazard model and read back from the metricSeries. -/// A wellness trend from your habits — NOT a clinical biological age. Recomputes the live best/worst -/// factor the same way the engine does, for the plain-English "why". +/// A wellness trend from your habits — NOT a clinical biological age. The best/worst driver comes from +/// the persisted per-driver years behind the same stored number, for the plain-English "why". private struct VitalitySection: View { @EnvironmentObject var repo: Repository @EnvironmentObject var profile: ProfileStore @@ -1204,16 +1204,14 @@ private struct VitalitySection: View { @State private var bodyAge: Double? @State private var loaded = false - /// The "what's driving it" inputs, from the ONE shared loader (`HealthspanDrivers.thisWeek`). - /// - /// This card used to assemble its own six-driver inputs here. That was already a second recipe for the - /// number beside it, and it broke outright when the engine grew to ten drivers: the card would have gone - /// on naming a "helping most" chosen from six, while the Body Age it sits under was computed from ten. - @State private var contributions: [VitalityEngine.Contribution] = [] + /// The "what's driving it" drivers, from the ONE shared loader (`HealthspanDrivers.latest`): the + /// persisted per-driver years behind the same stored Body Age this card shows, so "helping most" is + /// chosen from exactly the shares that number was summed from. + @State private var drivers: [HealthspanDriver] = [] var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Vitality", overline: "Weekly", + SectionHeader("Vitality", overline: "6 months", trailing: bodyAge != nil ? String(localized: "Body Age \(Int((bodyAge ?? 0).rounded()))") : nil) if let v = vitality, let ba = bodyAge { hero(vitality: v, bodyAge: ba) @@ -1230,7 +1228,7 @@ private struct VitalitySection: View { let delta = Double(profile.age) - ba let younger = delta >= 0 let yrs = Int(abs(delta).rounded()) - let sorted = contributions.sorted { $0.lnHazard < $1.lnHazard } + let sorted = drivers.sorted { $0.years < $1.years } let best = sorted.first let worst = sorted.last return VStack(alignment: .leading, spacing: NoopMetrics.space4) { @@ -1266,14 +1264,14 @@ private struct VitalitySection: View { .foregroundStyle(younger ? StrandPalette.statusPositive : StrandPalette.statusWarning) } } - if (best?.lnHazard ?? 0) < 0 || (worst?.lnHazard ?? 0) > 0 { + if (best?.years ?? 0) < 0 || (worst?.years ?? 0) > 0 { Divider().overlay(StrandPalette.hairline) - if let best, best.lnHazard < 0 { - Text("Helping most: \(best.label)") + if let best, best.years < 0 { + Text("Helping most: \(HealthspanDrivers.label(best.key))") .font(StrandFont.footnote).foregroundStyle(StrandPalette.statusPositive) } - if let worst, worst.lnHazard > 0 { - Text("Holding you back: \(worst.label)") + if let worst, worst.years > 0 { + Text("Holding you back: \(HealthspanDrivers.label(worst.key))") .font(StrandFont.footnote).foregroundStyle(StrandPalette.statusWarning) } } @@ -1305,8 +1303,7 @@ private struct VitalitySection: View { private func load() async { vitality = (await repo.exploreSeries(key: "vitality", source: "my-whoop")).last?.value bodyAge = (await repo.exploreSeries(key: "body_age", source: "my-whoop")).last?.value - contributions = await HealthspanDrivers.thisWeek( - repo: repo, age: profile.age, sex: profile.sex, weightKg: profile.weightKg) + drivers = await HealthspanDrivers.latest(repo: repo) loaded = true } } diff --git a/Strand/Screens/HealthspanView.swift b/Strand/Screens/HealthspanView.swift index 27df89a934..c452ad2881 100644 --- a/Strand/Screens/HealthspanView.swift +++ b/Strand/Screens/HealthspanView.swift @@ -1,68 +1,270 @@ import SwiftUI +import Charts import StrandDesign import StrandAnalytics +import WhoopStore // HealthspanView.swift — Body Age and Pace of Aging on one screen, with the drivers behind them. // -// Two numbers that answer different questions. Body Age says WHERE you are: the years your habits read -// as, against your own calendar age. Pace of Aging says WHERE YOU ARE HEADING: whether that number is -// climbing, holding, or coming down, as a multiple of ordinary time. Both come out of the one -// mortality-hazard model in `VitalityEngine` (see its header), and the pace is literally the drift of the -// same summed log-hazard the Body Age is derived from, so they cannot tell different stories. +// Two numbers that answer different questions. Body Age says WHERE you are: the years your last six +// months of habits read as, against health targets (meeting every target reads as your own age). Pace of +// Aging says WHERE YOU ARE HEADING: how fast that number would move over the next six months if your last +// 30 days held. Both come out of the one mortality-hazard model in `VitalityEngine`. // -// ── WHAT THIS SCREEN DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────── +// ── ONE STORED POINT PER DAY, READ ONCE ────────────────────────────────────────────────────────────── // -// It never states the Body Age twice. The headline is the STORED weekly value — the same row Trends, the -// Metric Explorer and the Today card read — and the driver rows below it show each input's value against -// its reference and which side of it that falls on. They do NOT carry a per-driver "adds 1.2 years", -// because a years figure recomputed here, over a window assembled here, would be a second answer to a -// question the headline has already answered, free to disagree with it. What a driver is worth in years -// lives in exactly one place: the engine that wrote the stored number. +// Every readout here — the hero, the dial, each driver's years chip, the biggest lever and the trend +// charts — reads the rows `IntelligenceEngine.recomputeHealthspan` persisted, through ONE loader +// (`HealthspanSnapshot.load`) called once per refresh and handed down. The driver chips are the exact +// shares the headline was summed from, and the chart's last point IS the headline, so no two readouts on +// this screen can disagree. Nothing is recomputed from raw days here. (The driver-detail sheet draws each +// driver's published CURVE from the engine, which is a statement about the evidence, not about the user.) // -// The pace is shown with the margin persisted beside it. When the fitted trend is not distinguishable -// from zero the screen says "holding steady" and shows no multiple at all — a pace is a promise about -// where someone is heading, and there is no promise to make from noise. +// The pace is shown with the margin persisted beside it. When the margin covers 1× the screen says +// "holding steady" and shows no multiple — a pace is a promise about where someone is heading, and there +// is no promise to make from noise. struct HealthspanView: View { + @EnvironmentObject var repo: Repository + @EnvironmentObject var profile: ProfileStore + + @State private var snapshot: HealthspanSnapshot? + @State private var unlock = HealthspanUnlock(scoredDays: 0, age: 0) + @State private var hrv: (value: Double, norm: Double)? + @State private var loaded = false + var body: some View { ScreenScaffold(title: "Healthspan", subtitle: "Body Age & Pace of Aging") { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { - HealthspanHeroSection() - HealthspanDriversSection() + HealthspanHeroSection(snapshot: snapshot, unlock: unlock, loaded: loaded, age: profile.age) + if let snapshot { + HealthspanPaceSection(snapshot: snapshot) + HealthspanLeverSection(snapshot: snapshot) + HealthspanDriversSection(snapshot: snapshot, age: Double(profile.age), sex: profile.sex) + HealthspanTrendSection(snapshot: snapshot) + } + if let hrv { HealthspanContextSection(hrv: hrv.value, norm: hrv.norm) } HealthspanAboutSection() } } + .task(id: repo.refreshSeq) { await load() } + } + + private func load() async { + snapshot = await HealthspanSnapshot.load(repo: repo) + unlock = HealthspanUnlock.current(days: repo.days, age: Double(profile.age)) + let last30 = repo.days.suffix(30).compactMap { $0.avgHrv } + hrv = IntelligenceEngine.healthspanMedian(last30).map { + ($0, VitalityEngine.rmssdNorm(forAge: Double(profile.age))) + } + loaded = true } } -// MARK: - Body Age + Pace heroes +// MARK: - The one loader -private struct HealthspanHeroSection: View { - @EnvironmentObject var repo: Repository - @EnvironmentObject var profile: ProfileStore +/// One driver behind the stored Body Age, as the pipeline persisted it for the headline's day. +struct HealthspanDriver: Equatable { + let key: String + /// This driver's share of the Body Age offset, years (positive = adds to Body Age). + let years: Double + /// The six-month value it was scored from, and the target that scores zero. + let value: Double + let target: Double + /// The last-30-day value, when that window had one — the driver behind the pace. + let recent: Double? +} - @State private var bodyAge: Double? - @State private var vitality: Double? - @State private var pace: Double? - @State private var paceMargin: Double? - @State private var loaded = false +/// Everything this screen shows, read once from the persisted Healthspan rows for the newest day. +struct HealthspanSnapshot { + let day: String + let bodyAge: Double + let vitality: Double? + let pace: Double? + let paceMargin: Double? + let projectedBodyAge: Double? + /// Most costly first. + let drivers: [HealthspanDriver] + /// Up to six months, oldest first; the last point is the headline's day. + let bodyAgeTrend: [(day: String, value: Double)] + let paceTrend: [(day: String, value: Double)] + + /// The driver whose target would take the most years off — exactly its persisted share, since + /// reaching the target zeroes it (the engine's shrink is linear). + var lever: HealthspanDriver? { + drivers.filter { $0.years >= VitalityEngine.minLeverYears }.max { $0.years < $1.years } + } + + /// Nil when no Body Age has been stored yet. + @MainActor + static func load(repo: Repository) async -> HealthspanSnapshot? { + let window = PaceOfAgingEngine.baselineWindowDays + 14 + func series(_ key: String) async -> [(day: String, value: Double)] { + await repo.exploreSeries(key: key, source: "my-whoop", days: window) + } + let ages = await series(HealthspanSeries.bodyAge) + guard let head = ages.last else { return nil } + func on(_ key: String) async -> Double? { await series(key).last { $0.day == head.day }?.value } + + var drivers: [HealthspanDriver] = [] + for key in HealthspanSeries.drivers { + guard let years = await on(HealthspanSeries.years(key)), + let value = await on(HealthspanSeries.value(key)), + let target = await on(HealthspanSeries.target(key)) else { continue } + drivers.append(HealthspanDriver(key: key, years: years, value: value, target: target, + recent: await on(HealthspanSeries.recent(key)))) + } + let paces = await series(HealthspanSeries.pace) + return HealthspanSnapshot( + day: head.day, bodyAge: head.value, + vitality: await on(HealthspanSeries.vitality), + pace: paces.last { $0.day == head.day }?.value, + paceMargin: await on(HealthspanSeries.paceMargin), + projectedBodyAge: await on(HealthspanSeries.projectedBodyAge), + drivers: drivers.sorted { $0.years > $1.years }, + bodyAgeTrend: ages.filter { $0.day <= head.day }, + paceTrend: paces.filter { $0.day <= head.day }) + } +} + +/// The drivers behind the stored headline, for screens other than this one (the Health hub's card). +enum HealthspanDrivers { + + @MainActor + static func latest(repo: Repository) async -> [HealthspanDriver] { + await HealthspanSnapshot.load(repo: repo)?.drivers ?? [] + } + + /// A driver's display name. + /// + /// Mapped from the key here rather than taken from `VitalityEngine.Contribution.label`: that label is + /// authored inside the platform-pure analytics package, which ships no string catalog, so rendering it + /// would put the one word naming each row into English on every device regardless of locale. + static func label(_ key: String) -> String { + switch key { + case "rhr": return String(localized: "Resting heart rate") + case "vo2max": return String(localized: "Cardio fitness") + case "sleep": return String(localized: "Sleep duration") + case "consistency": return String(localized: "Sleep regularity") + case "steps": return String(localized: "Daily steps") + case "moderate": return String(localized: "Zones 1–3 cardio") + case "vigorous": return String(localized: "Zones 4–5 cardio") + case "strength": return String(localized: "Strength training") + case "leanmass": return String(localized: "Lean mass") + default: return key + } + } + + /// A value in its driver's unit. Units and numbers only — nothing to translate. + static func format(_ value: Double, _ key: String) -> String { + switch key { + case "steps": return "\(Int(value.rounded())) steps/day" + case "sleep": return String(format: "%.1f h", value) + case "consistency": return "SRI \(Int(value.rounded()))" + case "rhr": return "\(Int(value.rounded())) bpm" + case "vo2max": return String(format: "%.1f ml/kg/min", value) + case "leanmass": return "\(Int(value.rounded()))%" + default: return "\(Int(value.rounded())) min/wk" + } + } + + /// The target as a user reads it: sleep's is a band, the others a single value. + static func formatTarget(_ d: HealthspanDriver) -> String { + d.key == "sleep" + ? String(format: "%.0f–%.0f h", VitalityEngine.sleepTargetLow, VitalityEngine.sleepTargetHigh) + : format(d.target, d.key) + } + + /// A signed years figure ("+1.2 yrs", "−0.4 yrs"). + static func years(_ y: Double) -> String { + let sign = y > 0.05 ? "+" : (y < -0.05 ? "−" : "") + return String(localized: "\(sign + String(format: "%.1f", abs(y))) yrs") + } +} + +/// The 21-scored-days-in-31 gate, counted from the same daily rows the pipeline gates on. +struct HealthspanUnlock { + let scoredDays: Int + let age: Double + var status: (unlocked: Bool, daysUntilUnlock: Int) { + VitalityEngine.unlockStatus(scoredDaysInWindow: scoredDays, age: age) + } + + @MainActor + static func current(days: [DailyMetric], age: Double) -> HealthspanUnlock { + guard let today = PaceOfAgingEngine.dayIndex(Repository.dayString(Date())) else { + return HealthspanUnlock(scoredDays: 0, age: age) + } + let window = (today - VitalityEngine.unlockWindowDays + 1)...today + let scored = days.filter { + $0.restingHr != nil && PaceOfAgingEngine.dayIndex($0.day).map(window.contains) == true + }.count + return HealthspanUnlock(scoredDays: scored, age: age) + } +} + +/// The two pure display decisions the screen makes from the persisted rows, kept out of the views so they +/// are testable. +enum HealthspanReadout { + + /// WHOOP's four readings of the −1× … 3× scale. `steady` is decided by the margin, not a band edge: + /// when the ± band covers 1× there is no direction to report. + enum Band { case reversing, slowing, steady, accelerating } + + static func band(pace: Double, margin: Double) -> Band { + if abs(pace - 1) <= margin { return .steady } + if pace < 0 { return .reversing } + return pace < 1 ? .slowing : .accelerating + } + + enum Trend { case better, worse } + + /// Whether the last 30 days sit closer to (or further past) the target than the six months do — the + /// driver behind the pace. Nil when the change is too small to call (under 3 % of the target) or the + /// 30-day window had no reading. + static func trend(_ d: HealthspanDriver) -> Trend? { + guard let recent = d.recent else { return nil } + let tolerance = abs(d.target) * 0.03 + let change: Double + switch d.key { + case "rhr": + change = d.value - recent // lower is better + case "sleep": + // Better is closer to the 7–9 h band. + func gap(_ h: Double) -> Double { + max(0, VitalityEngine.sleepTargetLow - h, h - VitalityEngine.sleepTargetHigh) + } + change = gap(d.value) - gap(recent) + default: + change = recent - d.value // higher is better + } + if abs(change) < tolerance { return nil } + return change > 0 ? .better : .worse + } +} + +// MARK: - Hero + +private struct HealthspanHeroSection: View { + let snapshot: HealthspanSnapshot? + let unlock: HealthspanUnlock + let loaded: Bool + let age: Int var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Where you stand", overline: "Weekly") - if let bodyAge { - bodyAgeCard(bodyAge) + SectionHeader("Where you stand", overline: "6 months") + if let snapshot { + bodyAgeCard(snapshot) } else if loaded { - ComingSoon(what: "A few more days of wear and we can show your Body Age.", symbol: "figure.stand") + unlockCard } else { ComingSoon(what: "Reading your Body Age…", symbol: "figure.stand") } - paceCard } - .task(id: repo.refreshSeq) { await load() } } - private func bodyAgeCard(_ years: Double) -> some View { - let delta = Double(profile.age) - years + private func bodyAgeCard(_ s: HealthspanSnapshot) -> some View { + let delta = Double(age) - s.bodyAge let younger = delta >= 0 let wholeYears = Int(abs(delta).rounded()) return NoopCard { @@ -70,14 +272,14 @@ private struct HealthspanHeroSection: View { HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.space3) { VStack(alignment: .leading, spacing: NoopMetrics.space1) { Text("Body Age").strandOverline() - Text("\(Int(years.rounded()))") + Text(String(format: "%.1f", s.bodyAge)) .font(StrandFont.number(44)) .foregroundStyle(StrandPalette.textPrimary) } Spacer(minLength: 0) VStack(alignment: .trailing, spacing: NoopMetrics.space1) { Text("Your age").strandOverline() - Text("\(profile.age)") + Text("\(age)") .font(StrandFont.number(28)) .foregroundStyle(StrandPalette.textSecondary) } @@ -85,7 +287,7 @@ private struct HealthspanHeroSection: View { Text(deltaLine(years: wholeYears, younger: younger)) .font(StrandFont.headline) .foregroundStyle(younger ? StrandPalette.statusPositive : StrandPalette.statusWarning) - if let vitality { + if let vitality = s.vitality { Text("Vitality \(Int(vitality.rounded())) of 100") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) @@ -99,69 +301,146 @@ private struct HealthspanHeroSection: View { .accessibilityElement(children: .combine) } - @ViewBuilder - private var paceCard: some View { + /// Before the first Body Age: how far off it is, counted on the same gate the pipeline applies. + private var unlockCard: some View { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Body Age").strandOverline() + if unlock.age < VitalityEngine.minAge { + Text("Healthspan is for adults 18 and over.") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + } else { + Text("\(min(unlock.scoredDays, VitalityEngine.unlockMinScoredDays)) of \(VitalityEngine.unlockMinScoredDays) scored days in the last 31") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + ProgressView(value: Double(min(unlock.scoredDays, VitalityEngine.unlockMinScoredDays)), + total: Double(VitalityEngine.unlockMinScoredDays)) + .tint(StrandPalette.accent) + Text("Body Age compares your last six months with health targets. Keep wearing the strap overnight.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .accessibilityElement(children: .combine) + } + + private func deltaLine(years: Int, younger: Bool) -> String { + if years == 0 { return String(localized: "About your age") } + switch (younger, years == 1) { + case (true, true): return String(localized: "1 yr younger than your age") + case (true, false): return String(localized: "\(years) yrs younger than your age") + case (false, true): return String(localized: "1 yr older than your age") + case (false, false): return String(localized: "\(years) yrs older than your age") + } + } +} + +// MARK: - Pace dial + +private struct HealthspanPaceSection: View { + let snapshot: HealthspanSnapshot + + typealias Band = HealthspanReadout.Band + + var body: some View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.space3) { Text("Pace of Aging").strandOverline() - if let pace, let paceMargin { - // The margin is the 95% band the engine fitted. When it covers 1x the trend is not - // distinguishable from holding steady, and no multiple is shown — see the file header. - if abs(pace - 1) <= paceMargin { - Text("Holding steady") - .font(StrandFont.number(30)) - .foregroundStyle(StrandPalette.textPrimary) - Text("Your recent habits are keeping your Body Age where it is.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } else { - Text(Self.multiple(pace)) - .font(StrandFont.number(38)) - .foregroundStyle(pace < 1 ? StrandPalette.statusPositive : StrandPalette.statusWarning) - Text(paceLine(pace)) + if let pace = snapshot.pace, let margin = snapshot.paceMargin { + let band = HealthspanReadout.band(pace: pace, margin: margin) + HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.space2) { + Text(band == .steady ? String(localized: "Holding steady") : Self.multiple(pace)) + .font(StrandFont.number(band == .steady ? 30 : 38)) + .foregroundStyle(tint(band)) + Spacer(minLength: 0) + Text(bandName(band)) + .font(StrandFont.caption) + .foregroundStyle(tint(band)) + } + Text(line(band)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + scale(pace: pace) + Text("± \(Self.multiple(margin)) · if your last 30 days hold for six months") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + if let projected = snapshot.projectedBodyAge { + Text("Projected Body Age in six months: \(String(format: "%.1f", projected))") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) } - paceScale(pace: pace) - } else if loaded { + } else { Text("Not enough history yet") .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) - Text("The pace is a trend, not a snapshot: it needs about two months of wear before it can tell a direction from a quiet week.") + Text("The pace compares your last 30 days with your last six months, so it appears once both can be scored.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) - } else { - Text("Reading your pace…") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) } } } } - /// The −1× … 3× scale with a marker at the fitted pace and a tick at 1× (holding steady). - private func paceScale(pace: Double) -> some View { + private func tint(_ band: Band) -> Color { + switch band { + case .reversing, .slowing: return StrandPalette.statusPositive + case .steady: return StrandPalette.textPrimary + case .accelerating: return StrandPalette.statusWarning + } + } + + private func bandName(_ band: Band) -> String { + switch band { + case .reversing: return String(localized: "Reversing") + case .slowing: return String(localized: "Slowing") + case .steady: return String(localized: "Steady") + case .accelerating: return String(localized: "Accelerating") + } + } + + /// Whole-phrase variants per band, so translators never see a stitched fragment. + private func line(_ band: Band) -> String { + switch band { + case .steady: return String(localized: "Your recent habits are keeping your Body Age where it is.") + case .accelerating: return String(localized: "Your recent habits are pushing your Body Age up.") + case .reversing, .slowing: return String(localized: "Your recent habits are pulling your Body Age down.") + } + } + + /// The −1× … 3× scale in its four bands, a marker at the pace and a tick at 1× (holding steady). + private func scale(pace: Double) -> some View { let lo = PaceOfAgingEngine.minPace, hi = PaceOfAgingEngine.maxPace - let fraction = max(0, min(1, (pace - lo) / (hi - lo))) - let steadyFraction = (1 - lo) / (hi - lo) + let x: (Double) -> Double = { max(0, min(1, ($0 - lo) / (hi - lo))) } + let segments: [(from: Double, to: Double, color: Color)] = [ + (lo, 0, StrandPalette.statusPositive.opacity(0.55)), + (0, 1, StrandPalette.statusPositive.opacity(0.25)), + (1, hi, StrandPalette.statusWarning.opacity(0.3)), + ] return VStack(alignment: .leading, spacing: NoopMetrics.space1) { GeometryReader { geo in ZStack(alignment: .leading) { - Capsule().fill(StrandPalette.surfaceInset) + ForEach(segments.indices, id: \.self) { i in + let s = segments[i] + Capsule().fill(s.color) + .frame(width: geo.size.width * (x(s.to) - x(s.from))) + .offset(x: geo.size.width * x(s.from)) + } Capsule() .fill(StrandPalette.hairlineStrong) .frame(width: NoopMetrics.hairlineWidth) - .offset(x: geo.size.width * steadyFraction) + .offset(x: geo.size.width * x(1)) Circle() - .fill(pace < 1 ? StrandPalette.statusPositive : StrandPalette.statusWarning) - .frame(width: 12, height: 12) - .offset(x: max(0, geo.size.width * fraction - 6)) + .fill(pace <= 1 ? StrandPalette.statusPositive : StrandPalette.statusWarning) + .frame(width: NoopMetrics.space3, height: NoopMetrics.space3) + .offset(x: max(0, geo.size.width * x(pace) - NoopMetrics.space3 / 2)) } } - .frame(height: 12) + .frame(height: NoopMetrics.space3) HStack { Text(Self.multiple(lo, decimals: 0)) .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) @@ -176,227 +455,323 @@ private struct HealthspanHeroSection: View { .accessibilityLabel(Text("Pace of aging \(Self.multiple(pace)), where 1× is holding steady")) } - /// A bare multiple ("1.4×"). A number and a sign — nothing to translate, so it is formatted rather - /// than carried through the String Catalog as copy. - private static func multiple(_ value: Double, decimals: Int = 1) -> String { + /// A bare multiple ("1.4×"). A number and a sign — nothing to translate. + static func multiple(_ value: Double, decimals: Int = 1) -> String { String(format: "%.\(decimals)f×", value) } - - /// Whole-phrase variants per direction, so translators never see a stitched fragment. - private func paceLine(_ pace: Double) -> String { - pace < 1 - ? String(localized: "Your recent habits are pulling your Body Age down.") - : String(localized: "Your recent habits are pushing your Body Age up.") - } - - private func deltaLine(years: Int, younger: Bool) -> String { - if years == 0 { return String(localized: "About your age") } - switch (younger, years == 1) { - case (true, true): return String(localized: "1 yr younger than your age") - case (true, false): return String(localized: "\(years) yrs younger than your age") - case (false, true): return String(localized: "1 yr older than your age") - case (false, false): return String(localized: "\(years) yrs older than your age") - } - } - - private func load() async { - bodyAge = (await repo.exploreSeries(key: "body_age", source: "my-whoop")).last?.value - vitality = (await repo.exploreSeries(key: "vitality", source: "my-whoop")).last?.value - pace = (await repo.exploreSeries(key: "pace_of_aging", source: "my-whoop")).last?.value - paceMargin = (await repo.exploreSeries(key: "pace_of_aging_margin", source: "my-whoop")).last?.value - loaded = true - } } -// MARK: - The one loader both Healthspan and the Health hub's Vitality card read +// MARK: - Biggest lever -/// Assembles THIS WEEK's Healthspan drivers, once, for every screen that shows them. -/// -/// It exists because there are two such screens, and before it there were two recipes: the Health hub's -/// Vitality card built its own six-driver inputs inline, so when the engine grew to ten the card kept -/// naming a "helping most" from a model the headline beside it was no longer computed with. The builder -/// itself (`IntelligenceEngine.healthspanInputs`) is the same one the analytics pass uses, so what a -/// screen shows and what the stored number was made of cannot drift apart either. -enum HealthspanDrivers { +private struct HealthspanLeverSection: View { + let snapshot: HealthspanSnapshot - /// The per-driver contributions for the last 7 days of `repo`, or empty when nothing is measurable yet. - @MainActor - static func thisWeek(repo: Repository, age: Int, sex: String, - weightKg: Double) async -> [VitalityEngine.Contribution] { - let last7 = Array(repo.days.suffix(7)) - guard !last7.isEmpty else { return [] } - let days = last7.map { $0.day } - async let moderateA = repo.exploreSeries(key: "zone_min_2_3", source: "my-whoop") - async let vigorousA = repo.exploreSeries(key: "zone_min_4_5", source: "my-whoop") - async let strengthA = repo.exploreSeries(key: "strength_min", source: "my-whoop") - async let leanA = repo.exploreSeries(key: "lean_mass", source: "apple-health") - let (moderate, vigorous, strength, lean) = await (moderateA, vigorousA, strengthA, leanA) - - let wanted = Set(days) - func byDay(_ points: [(day: String, value: Double)]) -> [String: Double] { - Dictionary(points.filter { wanted.contains($0.day) }.map { ($0.day, $0.value) }, - uniquingKeysWith: { _, b in b }) - } - let moderateByDay = byDay(moderate), vigorousByDay = byDay(vigorous) - var zone: [String: (moderate: Double, vigorous: Double)] = [:] - for day in days where moderateByDay[day] != nil || vigorousByDay[day] != nil { - zone[day] = (moderate: moderateByDay[day] ?? 0, vigorous: vigorousByDay[day] ?? 0) + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Biggest lever") + NoopCard { + HStack(alignment: .top, spacing: NoopMetrics.space3) { + Image(systemName: "arrow.down.forward.circle") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.statusPositive) + if let lever = snapshot.lever { + Text("Reaching your target for \(HealthspanDrivers.label(lever.key)) would take about \(String(format: "%.1f", lever.years)) years off your Body Age.") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } else { + Text("Every driver is on target. Nothing is costing you years.") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + } + } } - let inputs = IntelligenceEngine.healthspanInputs( - days: last7, zone: zone, strength: byDay(strength), - age: Double(age), sex: sex, - weightKg: weightKg > 0 ? weightKg : nil, - leanMassKg: lean.last?.value) - return VitalityEngine.contributions(inputs) } } // MARK: - The drivers -/// Each input behind the Body Age: what it measured this week and the reference it is measured against. -/// Built through `IntelligenceEngine.healthspanInputs`, the SAME builder the analytics pass uses, so the -/// rows describe the inputs the stored headline was actually computed from rather than a second recipe. +/// Each driver behind the Body Age: its six-month value against its target, the years it is worth, and +/// whether the last 30 days are moving it. Every figure is a persisted row behind the headline. private struct HealthspanDriversSection: View { - @EnvironmentObject var repo: Repository - @EnvironmentObject var profile: ProfileStore + let snapshot: HealthspanSnapshot + let age: Double + let sex: String - @State private var contributions: [VitalityEngine.Contribution] = [] - @State private var loaded = false + @State private var detail: HealthspanDriver? var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("What's behind it", overline: "Last 7 days") - if contributions.isEmpty { - ComingSoon(what: loaded - ? "Wear the strap for a few days and your drivers will appear here." - : "Reading your drivers…", symbol: "list.bullet") - } else { - ForEach(contributions, id: \.key) { row(for: $0) } - if missing.isEmpty == false { - Text("Not measured: \(missing.joined(separator: ", ")). Connect Apple Health or add the missing profile details to include them.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } + SectionHeader("What's behind it", overline: "6 months") + ForEach(snapshot.drivers, id: \.key) { d in + Button { detail = d } label: { row(for: d) } + .buttonStyle(.plain) + } + if missing.isEmpty == false { + Text("Not measured: \(missing.joined(separator: ", ")). Connect Apple Health or add the missing profile details to include them.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } - .task(id: repo.refreshSeq) { await load() } + .sheet(item: Binding(get: { detail.map(DetailID.init) }, set: { detail = $0?.driver })) { item in + HealthspanDriverDetail(driver: item.driver, age: age, sex: sex) { detail = nil } + } } - private func row(for c: VitalityEngine.Contribution) -> some View { + private struct DetailID: Identifiable { + let driver: HealthspanDriver + var id: String { driver.key } + } + + private func row(for d: HealthspanDriver) -> some View { NoopCard { HStack(alignment: .center, spacing: NoopMetrics.space3) { VStack(alignment: .leading, spacing: NoopMetrics.space1) { - Text(Self.driverLabel(c)) - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - Text("\(format(c.value, c)) · target \(format(c.target, c))") + HStack(spacing: NoopMetrics.space1) { + Text(HealthspanDrivers.label(d.key)) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + if let trend = HealthspanReadout.trend(d) { + Image(systemName: trend == .better ? "arrow.up.right" : "arrow.down.right") + .font(StrandFont.caption) + .foregroundStyle(trend == .better ? StrandPalette.statusPositive + : StrandPalette.statusWarning) + .accessibilityLabel(trend == .better ? Text("Last 30 days: better") + : Text("Last 30 days: worse")) + } + } + Text("\(HealthspanDrivers.format(d.value, d.key)) · target \(HealthspanDrivers.formatTarget(d))") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) } Spacer(minLength: NoopMetrics.space2) - Text(Self.statusLabel(c)) + Text(HealthspanDrivers.years(d.years)) .font(StrandFont.caption) - .foregroundStyle(statusTint(c)) + .foregroundStyle(tint(d)) + .padding(.horizontal, NoopMetrics.space2) + .padding(.vertical, NoopMetrics.spaceHalf) + .background(tint(d).opacity(0.14), in: Capsule(style: .continuous)) + Image(systemName: "chevron.right") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) } } .accessibilityElement(children: .combine) - .accessibilityLabel("\(Self.driverLabel(c)). \(format(c.value, c)), target \(format(c.target, c)). \(Self.statusLabel(c))") } - /// A driver's display name. - /// - /// Mapped from the key here rather than taken from `Contribution.label`: that label is authored inside - /// the platform-pure analytics package, which ships no string catalog, so rendering it would put the - /// one word naming each row into English on every device regardless of locale. The key is the stable - /// identifier; the name belongs to the screen. - static func driverLabel(_ c: VitalityEngine.Contribution) -> String { - switch c.key { - case "rhr": return String(localized: "Resting heart rate") - case "vo2max": return String(localized: "Cardio fitness") - case "sleep": return String(localized: "Sleep duration") - case "consistency": return String(localized: "Sleep regularity") - case "hrv": return String(localized: "Heart-rate variability") - case "steps": return String(localized: "Daily steps") - case "moderate": return String(localized: "Moderate cardio") - case "vigorous": return String(localized: "Vigorous cardio") - case "strength": return String(localized: "Strength training") - case "leanmass": return String(localized: "Lean mass") - default: return c.label + private func tint(_ d: HealthspanDriver) -> Color { + if d.years < -0.05 { return StrandPalette.statusPositive } + if d.years > 0.05 { return StrandPalette.statusWarning } + return StrandPalette.textSecondary + } + + private var missing: [String] { + let present = Set(snapshot.drivers.map { $0.key }) + return ["moderate", "vigorous", "strength", "leanmass", "vo2max"] + .filter { !present.contains($0) } + .map(HealthspanDrivers.label) + } +} + +/// One driver's published curve (hazard ratio against its target) with the user's value marked, and the +/// evidence it comes from. +private struct HealthspanDriverDetail: View { + let driver: HealthspanDriver + let age: Double + let sex: String + let onClose: () -> Void + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Risk against your target").strandOverline() + chart + Text("1.0 is your target. Above the line costs years; below it helps.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("\(HealthspanDrivers.format(driver.value, driver.key)) · target \(HealthspanDrivers.formatTarget(driver))") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(HealthspanDrivers.years(driver.years)) + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + Text("Source").strandOverline() + Text(verbatim: Self.citation(driver.key)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(NoopMetrics.screenPadding) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .navigationTitle(HealthspanDrivers.label(driver.key)) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) + ToolbarItem(placement: .topBarTrailing) { + Button("Done", action: onClose).foregroundStyle(StrandPalette.accent) + } + #else + ToolbarItem { Button("Done", action: onClose).foregroundStyle(StrandPalette.accent) } + #endif + } } + #if os(macOS) + .frame(minWidth: 420, minHeight: 460) + #endif } - /// A driver's standing against its own reference, from the sign of its log-hazard — the same quantity - /// the engine scored it on, so the chip cannot disagree with the verdict behind the number. - /// - /// The costly side names the DIRECTION the value sits from its target rather than a fixed word. The - /// chip read "Below target" for every costly driver, which is false for the lower-is-better and - /// two-sided ones: a resting HR of 72 against 65, or 9.4 h of sleep against 7.5 h, printed "Below - /// target" beside a number plainly above it. The helping side says "Better than target" because for - /// resting HR "better" is below and for steps it is above — only the verdict is the same. - static func statusLabel(_ c: VitalityEngine.Contribution) -> String { - if c.lnHazard < -0.001 { return String(localized: "Better than target") } - if c.lnHazard > 0.001 { - return c.value > c.target - ? String(localized: "Above target") - : String(localized: "Below target") + private var chart: some View { + let curve = Self.curve(driver.key, age: age, sex: sex) + let points = curve.map { c in + stride(from: c.domain.lowerBound, through: c.domain.upperBound, + by: (c.domain.upperBound - c.domain.lowerBound) / 80).map { (x: $0, hr: exp(c.f($0))) } + } ?? [] + let clamped = curve.map { min(max(driver.value, $0.domain.lowerBound), $0.domain.upperBound) } + return Chart { + ForEach(points, id: \.x) { p in + LineMark(x: .value("Value", p.x), y: .value("Hazard ratio", p.hr)) + .foregroundStyle(StrandPalette.accent) + } + RuleMark(y: .value("Target", 1.0)) + .foregroundStyle(StrandPalette.hairlineStrong) + .lineStyle(StrokeStyle(lineWidth: NoopMetrics.hairlineWidth, dash: [4, 4])) + if let c = curve, let x = clamped { + PointMark(x: .value("Value", x), y: .value("Hazard ratio", exp(c.f(x)))) + .foregroundStyle(driver.years > 0.05 ? StrandPalette.statusWarning : StrandPalette.statusPositive) + .symbolSize(80) + } } - return String(localized: "On target") + .frame(height: 200) + .accessibilityLabel(Text("Hazard ratio curve for \(HealthspanDrivers.label(driver.key))")) } - private func statusTint(_ c: VitalityEngine.Contribution) -> Color { - if c.lnHazard < -0.001 { return StrandPalette.statusPositive } - if c.lnHazard > 0.001 { return StrandPalette.statusWarning } - return StrandPalette.textSecondary + /// The engine curve for a driver over a readable domain. The same functions the score uses, drawn + /// before any overlap correction — a picture of the evidence, not a second computation of the user. + static func curve(_ key: String, age: Double, sex: String) + -> (domain: ClosedRange, f: (Double) -> Double)? { + switch key { + case "rhr": return (40...110, { VitalityEngine.restingHRLnHazard(bpm: $0, sex: sex) }) + case "vo2max": + let t = VitalityEngine.vo2maxTarget(age: age, sex: sex) + return (15...70, { VitalityEngine.vo2maxLnHazard(vo2max: $0, target: t) }) + case "sleep": return (3...12, { VitalityEngine.sleepDurationLnHazard(hours: $0) }) + case "consistency": return (20...100, { VitalityEngine.sriLnHazard(sri: $0) }) + case "steps": return (0...15000, { VitalityEngine.stepsLnHazard(steps: $0, age: age) }) + case "moderate": return (0...400, { VitalityEngine.moderateLnHazard(minPerWeek: $0, age: age) }) + case "vigorous": return (0...90, { VitalityEngine.vigorousLnHazard(minPerWeek: $0, age: age) }) + case "strength": return (0...200, { VitalityEngine.strengthLnHazard(minPerWeek: $0) }) + case "leanmass": return (50...95, { VitalityEngine.leanMassLnHazard(percent: $0, age: age, sex: sex) }) + default: return nil + } } - private func format(_ value: Double, _ c: VitalityEngine.Contribution) -> String { - let rounded: String - switch c.key { - case "steps": rounded = "\(Int(value.rounded()))" - case "consistency": return "\(Int((value * 100).rounded()))%" - case "sleep": rounded = String(format: "%.1f", value) - default: rounded = "\(Int(value.rounded()))" + /// The study behind each curve (bibliographic, so not translated). + static func citation(_ key: String) -> String { + switch key { + case "rhr": return "Zhang D et al., CMAJ 2016 (PMID 26598376); Aune D et al., Nutr Metab Cardiovasc Dis 2017 (PMID 28552551). +12 % all-cause mortality per +10 bpm." + case "vo2max": return "Kokkinos P et al., JACC 2022 (PMID 35926933); Lang JJ et al., Br J Sports Med 2024 (PMID 38599681); Mandsager K et al., JAMA Netw Open 2018. About 13 % lower mortality per MET." + case "sleep": return "UK Biobank device-measured sleep, J Gerontol A 2023 (doi 10.1093/gerona/glad108); Yin J et al., J Am Heart Assoc 2017. Short sleep HR 1.27, long 1.16 per hour outside 7–9 h." + case "consistency": return "Windred DP et al., SLEEP 2024 (doi 10.1093/sleep/zsad253); Cribb L et al., eLife 2023. Least regular quintile carries most of the hazard." + case "steps": return "Paluch AE et al., Lancet Public Health 2022 (doi 10.1016/S2468-2667(21)00302-9); Banach M et al., Eur J Prev Cardiol 2023." + case "moderate": return "Ekelund U et al., BMJ 2019 (doi 10.1136/bmj.l4570); Lee DH et al., Circulation 2022. Heart-rate-reserve zones 1–3." + case "vigorous": return "Ahmadi MN et al., Eur Heart J 2022 (doi 10.1093/eurheartj/ehac572); Stamatakis E et al., Nat Med 2022. 15 min/wk HR 0.82, 54 min/wk HR 0.64." + case "strength": return "Momma H et al., Br J Sports Med 2022 (doi 10.1136/bjsports-2021-105061). Benefit plateaus near 40–60 min/wk." + case "leanmass": return "Fat-free mass meta-analysis, J Cachexia Sarcopenia Muscle 2026 (doi 10.1002/jcsm.70331). Lean mass is an imported estimate, so this driver is the least certain." + default: return "" } - return c.unit.isEmpty ? rounded : "\(rounded) \(c.unit)" } +} - /// Drivers with no reading at all this week, named so the absence is visible rather than silent. - private var missing: [String] { - let present = Set(contributions.map { $0.key }) - return [("moderate", String(localized: "Moderate cardio")), - ("vigorous", String(localized: "Vigorous cardio")), - ("strength", String(localized: "Strength training")), - ("leanmass", String(localized: "Lean mass")), - ("vo2max", String(localized: "Cardio fitness"))] - .filter { !present.contains($0.0) } - .map { $0.1 } +// MARK: - Trend + +private struct HealthspanTrendSection: View { + let snapshot: HealthspanSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Trend", overline: "6 months") + if snapshot.bodyAgeTrend.count >= 2 { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("Body Age").strandOverline() + TrendChart(points: points(snapshot.bodyAgeTrend), + gradient: Gradient(colors: [StrandPalette.chargeColor.opacity(0.55), + StrandPalette.chargeColor]), + valueRange: domain(snapshot.bodyAgeTrend, pad: 1), + showsArea: false, height: 160, + valueFormat: { String(format: "%.1f", $0) }, + accessibilityLabel: String(localized: "Body Age over six months"), + yDomain: domain(snapshot.bodyAgeTrend, pad: 1)) + } + } + } + if snapshot.paceTrend.count >= 2 { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("Pace of Aging").strandOverline() + TrendChart(points: points(snapshot.paceTrend), + gradient: Gradient(colors: [StrandPalette.statusPositive, + StrandPalette.statusWarning]), + valueRange: PaceOfAgingEngine.minPace...PaceOfAgingEngine.maxPace, + showsArea: false, baselineValue: 1, height: 140, + valueFormat: { String(format: "%.1f×", $0) }, + accessibilityLabel: String(localized: "Pace of Aging over six months")) + } + } + } + } } - private func load() async { - contributions = await HealthspanDrivers.thisWeek( - repo: repo, age: profile.age, sex: profile.sex, weightKg: profile.weightKg) - loaded = true + private func points(_ series: [(day: String, value: Double)]) -> [TrendPoint] { + series.compactMap { p in + PaceOfAgingEngine.dayIndex(p.day).map { + TrendPoint(date: Date(timeIntervalSince1970: TimeInterval($0) * 86_400 + 43_200), value: p.value) + } + } + } + + private func domain(_ series: [(day: String, value: Double)], pad: Double) -> ClosedRange { + let values = series.map(\.value) + let lo = (values.min() ?? 0) - pad, hi = (values.max() ?? 0) + pad + return lo...max(hi, lo + 1) } } -// MARK: - What this is +// MARK: - Context (not scored) + +private struct HealthspanContextSection: View { + let hrv: Double + let norm: Double -private struct HealthspanAboutSection: View { var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("How this is worked out", overline: "On \(Platform.deviceNounPhrase)") + SectionHeader("Context", overline: "Not scored") NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.space2) { - Text("Every driver above is matched to its published all-cause-mortality hazard ratio from large cohort studies, and the combined result is converted into years using the rate at which mortality risk doubles with age.") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Text("Drivers that measure the same thing are not counted twice: your steps, cardio minutes and strength minutes are four views of how much you move, and they are weighed against each other before they count.") + Text("Heart-rate variability") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("\(String(Int(hrv.rounded()))) ms over the last 30 days · typical for your age \(String(Int(norm.rounded()))) ms") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Text("Nothing here leaves this \(Platform.deviceNounPhrase), and none of it is medical advice.") + Text("Shown for context only. HRV is not a driver: its link to lifespan does not hold up as cause and effect.") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -405,3 +780,114 @@ private struct HealthspanAboutSection: View { } } } + +// MARK: - Method + +private struct HealthspanAboutSection: View { + @State private var showMethod = false + + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("How this is worked out", overline: "On \(Platform.deviceNounPhrase)") + Button { showMethod = true } label: { + NoopCard { + HStack(spacing: NoopMetrics.space3) { + Image(systemName: "function") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.accent) + Text("Method and references") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: NoopMetrics.space2) + Image(systemName: "chevron.right") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + .buttonStyle(.plain) + Text("Nothing here leaves this \(Platform.deviceNounPhrase), and none of it is medical advice.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + .sheet(isPresented: $showMethod) { HealthspanMethodSheet { showMethod = false } } + } +} + +private struct HealthspanMethodSheet: View { + let onClose: () -> Void + + private static let references = [ + "WHOOP, The WHOOP Healthspan Feature, white paper rev. 2025-09-04", + "Spiegelhalter D, BMC Med Inform Decis Mak 2016;16:104", + "Windred DP et al., SLEEP 2024;47(1):zsad253", + "Cribb L et al., eLife 2023", + "UK Biobank device-measured sleep, J Gerontol A 2023", + "Ahmadi MN et al., Eur Heart J 2022", + "Stamatakis E et al., Nat Med 2022", + "Ekelund U et al., BMJ 2019", + "Lee DH et al., Circulation 2022", + "Momma H et al., Br J Sports Med 2022", + "Paluch AE et al., Lancet Public Health 2022", + "Kokkinos P et al., JACC 2022; Lang JJ et al., Br J Sports Med 2024", + "Zhang D et al., CMAJ 2016; Aune D et al., Nutr Metab Cardiovasc Dis 2017", + "Fat-free mass meta-analysis, J Cachexia Sarcopenia Muscle 2026", + "UK Biobank HRV Mendelian randomisation, Commun Biol 2023", + ] + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + paragraph("Each driver is compared with a health target, not with the average person. Meeting every target reads as your own age; the average adult reads several years older.") + paragraph("Each driver's distance from its target is matched to a published all-cause-mortality hazard ratio and converted to years at 10 × ln(hazard ratio).") + paragraph("Drivers that measure the same thing are not counted twice: steps, cardio minutes and strength minutes are weighed against each other, and a strap-estimated VO₂max is folded into resting heart rate.") + paragraph("Body Age uses your last six months. Pace of Aging holds your last 30 days for six months: 1× is holding steady.") + paragraph("The hazard ratios come from observational studies, and nothing here has been validated against lifespan. Treat it as a wellness trend, not a clinical biological age.") + } + } + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("References").strandOverline() + ForEach(Self.references, id: \.self) { ref in + Text(verbatim: ref) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .padding(NoopMetrics.screenPadding) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .navigationTitle("How this is worked out") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) + ToolbarItem(placement: .topBarTrailing) { + Button("Done", action: onClose).foregroundStyle(StrandPalette.accent) + } + #else + ToolbarItem { Button("Done", action: onClose).foregroundStyle(StrandPalette.accent) } + #endif + } + } + #if os(macOS) + .frame(minWidth: 460, minHeight: 520) + #endif + } + + private func paragraph(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/StrandTests/HealthspanInputsTests.swift b/StrandTests/HealthspanInputsTests.swift index bde407263a..42c39ab586 100644 --- a/StrandTests/HealthspanInputsTests.swift +++ b/StrandTests/HealthspanInputsTests.swift @@ -3,138 +3,171 @@ import XCTest import StrandAnalytics import WhoopStore -/// The orchestration half of Healthspan: how the analytics pass turns stored days and activity series into -/// `VitalityEngine.Inputs`, and how it lays those out as one rolling sample per day for the pace fit. +/// The orchestration half of Healthspan: how the analytics pass turns stored history into +/// `VitalityEngine.Inputs` for each window, and into the per-day points the screens read. /// /// The engines themselves are covered in the StrandAnalytics package, against a committed oracle. What is -/// only testable here is the ASSEMBLY: which days count as observed, how a partial week is scaled to a -/// weekly dose, and which windows are allowed to become a sample at all. -/// -/// `@MainActor`: the helpers are main-actor-isolated on `IntelligenceEngine`, so the fixture runs there. -@MainActor +/// only testable here is the ASSEMBLY: which days count as observed, how a partial window is scaled to a +/// weekly dose, which windows each point reads, and when a day is unlocked at all. final class HealthspanInputsTests: XCTestCase { + private let today = PaceOfAgingEngine.dayIndex("2026-09-23")! + /// A day carrying enough for every non-activity driver, so the assembly under test is the only variable. - private func day(_ key: String) -> DailyMetric { - DailyMetric(day: key, totalSleepMin: 450, efficiency: nil, deepMin: nil, remMin: nil, - lightMin: nil, disturbances: nil, restingHr: 60, avgHrv: 40, recovery: nil, - strain: nil, exerciseCount: nil, steps: 8000) + private func day(_ index: Int, rhr: Int? = 60, sleepMin: Double = 450, steps: Int = 8000) -> DailyMetric { + DailyMetric(day: IntelligenceEngine.healthspanDayKey(index, IntelligenceEngine.healthspanDayFormatter()), + totalSleepMin: sleepMin, efficiency: nil, deepMin: nil, remMin: nil, + lightMin: nil, disturbances: nil, restingHr: rhr, avgHrv: 40, recovery: nil, + strain: nil, exerciseCount: nil, steps: steps) } - /// `count` consecutive calendar days starting at `start`, oldest first. - private func consecutiveDayKeys(from start: String, count: Int) -> [String] { - (0.. Void = { _, _ in }) -> HealthspanHistory { + let last = end ?? today + var h = HealthspanHistory() + for i in (last - count + 1)...last { h.days[i] = day(i); edit(i, &h) } + return h } - // MARK: - Weekly dose assembly - - /// Five observed days of 20 moderate minutes is 100 minutes seen and a 140-minute WEEK: the doses are - /// stated per week whatever coverage the week had, the same treatment the sleep and step means get. - func testPartialWeekScalesToASevenDayDose() throws { - let keys = consecutiveDayKeys(from: "2026-09-01", count: 5) - let zone = Dictionary(uniqueKeysWithValues: keys.map { ($0, (moderate: 20.0, vigorous: 5.0)) }) - let inputs = IntelligenceEngine.healthspanInputs( - days: keys.map(day), zone: zone, strength: [:], age: 40, sex: "male", - weightKg: 80, leanMassKg: nil) - XCTAssertEqual(try XCTUnwrap(inputs.moderateMinPerWeek), 140, accuracy: 1e-9) - XCTAssertEqual(try XCTUnwrap(inputs.vigorousMinPerWeek), 35, accuracy: 1e-9) + private func inputs(_ h: HealthspanHistory, window: Int, end: Int? = nil) -> VitalityEngine.Inputs { + IntelligenceEngine.healthspanInputs(history: h, endDay: end ?? today, windowDays: window, + age: 40, sex: "male", profileWeightKg: 80) } - /// Below the coverage floor there is no dose at all. One logged day is not a week, and multiplying it - /// by seven would be an invention rather than a measurement. + // MARK: - Dose assembly + + /// Five observed days of 20 zone-1–3 minutes is a 140-minute WEEK: doses are stated per week whatever + /// coverage the window had. + func testPartialWindowScalesToASevenDayDose() throws { + let h = history(days: 7) { i, h in if i > self.today - 5 { h.zones[i] = (20, 5) } } + let x = inputs(h, window: 7) + XCTAssertEqual(try XCTUnwrap(x.moderateMinPerWeek), 140, accuracy: 1e-9) + XCTAssertEqual(try XCTUnwrap(x.vigorousMinPerWeek), 35, accuracy: 1e-9) + } + + /// Below the coverage floor there is no dose at all: one logged day is not a week. func testTooFewObservedDaysYieldsNoDose() { - let keys = consecutiveDayKeys(from: "2026-09-01", count: 3) - let zone = Dictionary(uniqueKeysWithValues: keys.map { ($0, (moderate: 60.0, vigorous: 30.0)) }) - let inputs = IntelligenceEngine.healthspanInputs( - days: keys.map(day), zone: zone, strength: [keys[0]: 45], age: 40, sex: "male", - weightKg: 80, leanMassKg: nil) - XCTAssertNil(inputs.moderateMinPerWeek) - XCTAssertNil(inputs.vigorousMinPerWeek) - XCTAssertNil(inputs.strengthMinPerWeek) - // The non-activity drivers are unaffected: a thin activity week still has a resting HR. - XCTAssertNotNil(inputs.restingHR) + let h = history(days: 30) { i, h in if i > self.today - 3 { h.zones[i] = (60, 30); h.strength[i] = 45 } } + let x = inputs(h, window: 30) + XCTAssertNil(x.moderateMinPerWeek) + XCTAssertNil(x.strengthMinPerWeek) + XCTAssertNotNil(x.restingHR, "a thin activity window still has a resting HR") } - /// A day we WATCHED with no strength entry did no strength training — zero, not unknown. Only a day the - /// strap actually saw can tell those apart, which is why observation is keyed on the zone reading. + /// A WATCHED day with no strength entry did no strength training — zero, not unknown. func testAWatchedDayWithNoStrengthCountsAsZero() throws { - let keys = consecutiveDayKeys(from: "2026-09-01", count: 7) - let zone = Dictionary(uniqueKeysWithValues: keys.map { ($0, (moderate: 10.0, vigorous: 0.0)) }) - let inputs = IntelligenceEngine.healthspanInputs( - days: keys.map(day), zone: zone, strength: [keys[2]: 35], age: 40, sex: "male", - weightKg: 80, leanMassKg: nil) - // 35 minutes across seven observed days, scaled to a seven-day week, is still 35. - XCTAssertEqual(try XCTUnwrap(inputs.strengthMinPerWeek), 35, accuracy: 1e-9) + let h = history(days: 14) { i, h in + h.zones[i] = (10, 0) + if i == self.today - 3 { h.strength[i] = 70 } + } + XCTAssertEqual(try XCTUnwrap(inputs(h, window: 14).strengthMinPerWeek), 35, accuracy: 1e-9) } - /// Days the strap never saw are not counted as sedentary: an unobserved day is absent from the dose, - /// not a zero dragging it down. - func testUnobservedDaysAreNotTreatedAsZero() throws { - let keys = consecutiveDayKeys(from: "2026-09-01", count: 7) - let zone = Dictionary(uniqueKeysWithValues: keys.prefix(4).map { ($0, (moderate: 30.0, vigorous: 0.0)) }) - let inputs = IntelligenceEngine.healthspanInputs( - days: keys.map(day), zone: zone, strength: [:], age: 40, sex: "male", - weightKg: 80, leanMassKg: nil) - // 120 minutes over 4 observed days is 30/day, so a 210-minute week — not the 120 actually seen. - XCTAssertEqual(try XCTUnwrap(inputs.moderateMinPerWeek), 210, accuracy: 1e-9) + // MARK: - The other drivers + + /// Regularity comes from the SRI agreement pairs inside the window, not from durations. + func testRegularityReadsTheWindowsPairs() throws { + let h = history(days: 40) { i, h in h.sriAgreement[i] = i > self.today - 10 ? 1.0 : 0.5 } + XCTAssertEqual(try XCTUnwrap(inputs(h, window: 7).sleepRegularity), 100, accuracy: 1e-9) + XCTAssertEqual(try XCTUnwrap(inputs(h, window: 30).sleepRegularity), 200 * ((9 + 21 * 0.5) / 30) - 100, + accuracy: 1e-9) + } + + /// An imported VO₂max is independent evidence and wins over the strap's estimate in its window. + func testExternalVO2maxWinsInItsWindow() { + let h = history(days: 60) { i, h in + if (i - self.today) % 7 == 0 { h.vo2max[i] = (40, .strap) } + if i == self.today - 40 { h.vo2max[i] = (50, .external) } + } + let recent = inputs(h, window: 30), baseline = inputs(h, window: 60) + XCTAssertEqual(recent.vo2max, 40) + XCTAssertEqual(recent.vo2maxSource, .strap) + XCTAssertEqual(baseline.vo2max, 50) + XCTAssertEqual(baseline.vo2maxSource, .external) + } + + /// Lean mass needs a weight; the window's own readings win over the profile weight. + func testLeanMassUsesTheWindowsWeight() { + let h = history(days: 30) { i, h in + if i == self.today - 2 { h.leanMass[i] = 60; h.weight[i] = 75 } + } + let x = inputs(h, window: 30) + XCTAssertEqual(x.leanMassKg, 60) + XCTAssertEqual(x.weightKg, 75) + XCTAssertNil(inputs(history(days: 30), window: 30).weightKg, "no lean mass → no weight carried") + } + + // MARK: - Points + + /// Locked below 21 scored days in the last 31; unlocked at 21. + func testUnlockGate() { + XCTAssertTrue(IntelligenceEngine.healthspanPoints( + history: history(days: 20), endDay: today, dayKey: "2026-09-23", + age: 40, sex: "male", profileWeightKg: 80).isEmpty) + XCTAssertFalse(IntelligenceEngine.healthspanPoints( + history: history(days: 21), endDay: today, dayKey: "2026-09-23", + age: 40, sex: "male", profileWeightKg: 80).isEmpty) + XCTAssertTrue(IntelligenceEngine.healthspanPoints( + history: history(days: 60), endDay: today, dayKey: "2026-09-23", + age: 17, sex: "male", profileWeightKg: 80).isEmpty, "adults only") } - /// Lean mass needs both halves of the percentage; a mass with no weight contributes nothing rather - /// than a wrong one. - func testLeanMassNeedsWeightAndMass() { - let keys = consecutiveDayKeys(from: "2026-09-01", count: 7) - let withBoth = IntelligenceEngine.healthspanInputs( - days: keys.map(day), zone: [:], strength: [:], age: 40, sex: "male", - weightKg: 80, leanMassKg: 60) - XCTAssertNotNil(VitalityEngine.contributions(withBoth).first { $0.key == "leanmass" }) - let noWeight = IntelligenceEngine.healthspanInputs( - days: keys.map(day), zone: [:], strength: [:], age: 40, sex: "male", - weightKg: nil, leanMassKg: 60) - XCTAssertNil(VitalityEngine.contributions(noWeight).first { $0.key == "leanmass" }) + /// The persisted per-driver years sum to the persisted Body Age offset — the property that lets a + /// screen show years per driver without recomputing anything. + func testPersistedDriverYearsSumToTheHeadline() throws { + let h = history(days: 200) { i, h in + h.zones[i] = (40, 2); h.strength[i] = i % 7 == 0 ? 30 : 0; h.sriAgreement[i] = 0.9 + } + let points = IntelligenceEngine.healthspanPoints(history: h, endDay: today, dayKey: "2026-09-23", + age: 40, sex: "male", profileWeightKg: 80) + let byKey = Dictionary(uniqueKeysWithValues: points.map { ($0.key, $0.value) }) + let years = HealthspanSeries.drivers.compactMap { byKey[HealthspanSeries.years($0)] } + XCTAssertGreaterThanOrEqual(years.count, 6) + XCTAssertEqual(years.reduce(0, +), try XCTUnwrap(byKey[HealthspanSeries.bodyAge]) - 40, accuracy: 1e-9) + XCTAssertNotNil(byKey[HealthspanSeries.pace]) + XCTAssertNotNil(byKey[HealthspanSeries.paceMargin]) + XCTAssertEqual(try XCTUnwrap(byKey[HealthspanSeries.projectedBodyAge]), + try XCTUnwrap(byKey[HealthspanSeries.bodyAge]) + 0.5 * byKey[HealthspanSeries.pace]!, + accuracy: 1e-9) + XCTAssertTrue(points.allSatisfy { HealthspanSeries.allKeys.contains($0.key) }, + "every written key is one a recomputation clears") } - // MARK: - Rolling samples for the pace fit - - /// One sample per day across the trend window, each carrying the factor signature behind it, on a real - /// time base the fit can use. - func testRollingSamplesSpanTheTrendWindow() { - let keys = consecutiveDayKeys(from: "2026-03-01", count: 200) - let samples = IntelligenceEngine.healthspanPaceSamples( - days: keys.map(day), zone: [:], strength: [:], age: 40, sex: "male", - weightKg: 80, leanMassKg: nil) - XCTAssertEqual(samples.count, PaceOfAgingEngine.trendWindowDays) - XCTAssertEqual(Set(samples.map { $0.factorSignature }).count, 1, - "an unchanging history must produce one signature, or the fit drops its own samples") - XCTAssertEqual(samples.map { $0.dayIndex }, samples.map { $0.dayIndex }.sorted()) - XCTAssertEqual(Set(samples.map { $0.dayIndex }).count, samples.count) - // Behaviour that never changes is a flat series, which is what the engine reads as 1×. - XCTAssertEqual(Set(samples.map { $0.lnHazardSum }).count, 1) - XCTAssertEqual(try XCTUnwrap(PaceOfAgingEngine.compute(samples: samples)).pace, 1.0, accuracy: 1e-9) + /// Behaviour that never changes ages at exactly 1×. + func testUnchangingHistoryAgesAtOneTimes() throws { + let h = history(days: 220) { i, h in h.zones[i] = (15, 1); h.sriAgreement[i] = 0.85 } + let points = IntelligenceEngine.healthspanPoints(history: h, endDay: today, dayKey: "2026-09-23", + age: 40, sex: "male", profileWeightKg: 80) + let pace = try XCTUnwrap(points.first { $0.key == HealthspanSeries.pace }).value + XCTAssertEqual(pace, 1, accuracy: 1e-9) } - /// The ragged start of someone's history is not fitted as a trend: a window holding fewer than - /// `minWindowDays` real days never becomes a sample. - func testThinWindowsAreNotSampled() { - let keys = consecutiveDayKeys(from: "2026-08-01", count: 40) - let samples = IntelligenceEngine.healthspanPaceSamples( - days: keys.map(day), zone: [:], strength: [:], age: 40, sex: "male", - weightKg: 80, leanMassKg: nil) - XCTAssertEqual(samples.count, 40 - PaceOfAgingEngine.minWindowDays + 1) - XCTAssertNil(PaceOfAgingEngine.compute(samples: samples), - "26 samples is below the pace engine's own floor, so no pace is reported") + /// A range of days yields one point set per unlocked day, keyed by the right calendar day. + func testRangeKeysEachDay() { + let h = history(days: 40) + let points = IntelligenceEngine.healthspanPoints(history: h, fromDay: today - 2, toDay: today, + age: 40, sex: "male", profileWeightKg: 80) + XCTAssertEqual(Set(points.filter { $0.key == HealthspanSeries.bodyAge }.map(\.day)), + ["2026-09-21", "2026-09-22", "2026-09-23"]) } - func testNoHistoryYieldsNoSamples() { - XCTAssertTrue(IntelligenceEngine.healthspanPaceSamples( - days: [], zone: [:], strength: [:], age: 40, sex: "male", - weightKg: 80, leanMassKg: nil).isEmpty) + /// The day-key helpers are inverses across month and leap boundaries. + func testDayKeysRoundTrip() { + let fmt = IntelligenceEngine.healthspanDayFormatter() + for key in ["2024-02-29", "2024-03-01", "2026-09-01", "2026-12-31"] { + XCTAssertEqual(IntelligenceEngine.healthspanDayKey(PaceOfAgingEngine.dayIndex(key)!, fmt), key) + } } - /// `dayKey` walks the calendar both ways and lands on real dates across a month boundary. - func testDayKeyWalksTheCalendar() { - XCTAssertEqual(IntelligenceEngine.dayKey(daysBefore: 1, before: "2026-09-01"), "2026-08-31") - XCTAssertEqual(IntelligenceEngine.dayKey(daysBefore: -1, before: "2026-08-31"), "2026-09-01") - XCTAssertEqual(IntelligenceEngine.dayKey(daysBefore: 0, before: "2026-09-22"), "2026-09-22") - XCTAssertEqual(IntelligenceEngine.dayKey(daysBefore: 1, before: "2024-03-01"), "2024-02-29") + /// The HRR zone pair from the store: a day with either series is observed. + func testZonesFromStoredSeries() { + let z = IntelligenceEngine.healthspanZones( + moderate: [MetricPoint(day: "2026-09-01", key: HealthspanSeries.zoneModerate, value: 30)], + vigorous: [MetricPoint(day: "2026-09-01", key: HealthspanSeries.zoneVigorous, value: 4), + MetricPoint(day: "2026-09-02", key: HealthspanSeries.zoneVigorous, value: 6)]) + XCTAssertEqual(z[PaceOfAgingEngine.dayIndex("2026-09-01")!]?.moderate, 30) + XCTAssertEqual(z[PaceOfAgingEngine.dayIndex("2026-09-01")!]?.vigorous, 4) + XCTAssertEqual(z[PaceOfAgingEngine.dayIndex("2026-09-02")!]?.moderate, 0) } } diff --git a/StrandTests/HealthspanReadoutTests.swift b/StrandTests/HealthspanReadoutTests.swift new file mode 100644 index 0000000000..4ea715117a --- /dev/null +++ b/StrandTests/HealthspanReadoutTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import Strand +import StrandAnalytics + +/// The Healthspan screen's own decisions on top of the persisted rows: the pace band, the 30-day arrow, +/// the biggest lever and the signed years chip. +final class HealthspanReadoutTests: XCTestCase { + + private func driver(_ key: String, years: Double = 0, value: Double, target: Double, + recent: Double? = nil) -> HealthspanDriver { + HealthspanDriver(key: key, years: years, value: value, target: target, recent: recent) + } + + /// A margin covering 1× is steady whatever the point estimate; otherwise the band follows the pace. + func testPaceBands() { + XCTAssertEqual(HealthspanReadout.band(pace: 1.8, margin: 0.9), .steady) + XCTAssertEqual(HealthspanReadout.band(pace: -0.4, margin: 0.3), .reversing) + XCTAssertEqual(HealthspanReadout.band(pace: 0.5, margin: 0.3), .slowing) + XCTAssertEqual(HealthspanReadout.band(pace: 1.6, margin: 0.3), .accelerating) + } + + /// The arrow knows which way is better for each driver, and stays silent on a change too small to call. + func testTrendDirection() { + XCTAssertEqual(HealthspanReadout.trend(driver("rhr", value: 62, target: 60, recent: 58)), .better) + XCTAssertEqual(HealthspanReadout.trend(driver("steps", value: 6000, target: 8000, recent: 5000)), .worse) + XCTAssertEqual(HealthspanReadout.trend(driver("sleep", value: 6.2, target: 7, recent: 7.4)), .better) + XCTAssertEqual(HealthspanReadout.trend(driver("sleep", value: 8, target: 7, recent: 9.8)), .worse) + XCTAssertNil(HealthspanReadout.trend(driver("steps", value: 8000, target: 8000, recent: 8100))) + XCTAssertNil(HealthspanReadout.trend(driver("steps", value: 8000, target: 8000))) + } + + /// The lever is the costliest driver above the floor, and nothing when every driver is close enough. + func testLever() { + func snapshot(_ drivers: [HealthspanDriver]) -> HealthspanSnapshot { + HealthspanSnapshot(day: "2026-09-23", bodyAge: 40, vitality: nil, pace: nil, paceMargin: nil, + projectedBodyAge: nil, drivers: drivers, bodyAgeTrend: [], paceTrend: []) + } + let s = snapshot([driver("steps", years: 1.4, value: 5000, target: 8000), + driver("strength", years: 2.1, value: 0, target: 40), + driver("rhr", years: -0.6, value: 52, target: 60)]) + XCTAssertEqual(s.lever?.key, "strength") + XCTAssertNil(snapshot([driver("steps", years: 0.1, value: 7900, target: 8000)]).lever) + } + + func testSignedYears() { + XCTAssertTrue(HealthspanDrivers.years(1.23).hasPrefix("+1.2")) + XCTAssertTrue(HealthspanDrivers.years(-0.44).hasPrefix("−0.4")) + XCTAssertTrue(HealthspanDrivers.years(0.01).hasPrefix("0.0")) + XCTAssertEqual(HealthspanDrivers.formatTarget(driver("sleep", value: 7.5, target: 7)), "7–9 h") + } +} diff --git a/StrandTests/StaleWeeklyAndRestPointTests.swift b/StrandTests/StaleWeeklyAndRestPointTests.swift index 9dc40856a7..1352460a13 100644 --- a/StrandTests/StaleWeeklyAndRestPointTests.swift +++ b/StrandTests/StaleWeeklyAndRestPointTests.swift @@ -3,8 +3,9 @@ import StrandAnalytics import WhoopStore @testable import Strand -/// A partial first day or a since-dropped session must not leave a score behind: the weekly Vitality / -/// Body Age needs enough scored nights, and a `sleep_performance` point whose day lost its sleep is stale. +/// A since-dropped session must not leave a score behind: a `sleep_performance` point whose day lost its +/// sleep is stale. (The Body Age half of this — too few scored nights — is the 21-of-31 unlock gate, now +/// covered in `HealthspanInputsTests.testUnlockGate`.) final class StaleWeeklyAndRestPointTests: XCTestCase { private func day(_ key: String, rhr: Int?, sleepMin: Double? = nil) -> DailyMetric { @@ -16,15 +17,6 @@ final class StaleWeeklyAndRestPointTests: XCTestCase { sleepHrOnly: nil) } - func testOneScoredNightIsNotAWeeklyBodyAge() { - let week = [day("2026-09-18", rhr: 60)] - XCTAssertFalse(IntelligenceEngine.healthspanWeekIsScorable(week)) - let three = (19...21).map { day("2026-09-\($0)", rhr: 52) } + [day("2026-09-22", rhr: nil)] - XCTAssertFalse(IntelligenceEngine.healthspanWeekIsScorable(three)) - let four = (19...22).map { day("2026-09-\($0)", rhr: 52) } - XCTAssertTrue(IntelligenceEngine.healthspanWeekIsScorable(four)) - } - func testRestPointForADayWithoutSleepIsStale() { let persisted = [day("2026-09-18", rhr: nil), day("2026-09-19", rhr: 53, sleepMin: 520)] let produced = [MetricPoint(day: "2026-09-19", key: "sleep_performance", value: 90), diff --git a/docs/superpowers/plans/2026-09-23-healthspan-v2.md b/docs/superpowers/plans/2026-09-23-healthspan-v2.md index 14a809b0d4..21f0a5063f 100644 --- a/docs/superpowers/plans/2026-09-23-healthspan-v2.md +++ b/docs/superpowers/plans/2026-09-23-healthspan-v2.md @@ -15,9 +15,9 @@ observational, and nothing here is validated against a mortality outcome (WHOOP' ## Checklist -- [ ] **PR 1 — Engine** (`Packages/StrandAnalytics`: `VitalityEngine` + `PaceOfAgingEngine`, `swift test`) -- [ ] **PR 2 — Pipeline** (`Strand/Data/IntelligenceEngine.swift`: windows, inputs, persisted breakdown) -- [ ] **PR 3 — Healthspan tab** (`Strand/Screens/HealthspanView.swift`, macOS + iOS) +- [x] **PR 1 — Engine** (`Packages/StrandAnalytics`: `VitalityEngine` + `PaceOfAgingEngine`, `swift test`) +- [x] **PR 2 — Pipeline** (`Strand/Data/IntelligenceEngine.swift`: windows, inputs, persisted breakdown) +- [x] **PR 3 — Healthspan tab** (`Strand/Screens/HealthspanView.swift`, macOS + iOS) ## What is wrong with the current feature (found 2026-09-23) @@ -42,7 +42,7 @@ observational, and nothing here is validated against a mortality outcome (WHOOP' | Body Age window | **Last 182 days** | WHOOP Age uses 6 months. | | Pace of Aging | **Projection**: hold the last-30-day averages for 6 months → projected Body Age; pace = (projected − current) / 0.5 y, clamped −1…3× | WHOOP's published definition (WP p.19). Keep the current honesty rule: ±margin, and "holding steady" when the margin spans 1×. | | Unlock | ≥ 21 scored days in the last 31; age ≥ 18 | WHOOP's gate. | -| Cadence | Weekly headline (Sunday, like WHOOP) plus a daily point for the trend chart | Comparable to WHOOP while the tab still moves daily. | +| Cadence | ~~Weekly headline (Sunday, like WHOOP) plus a daily point for the trend chart~~ **One point per day; the headline is the newest point** (revised in PR 2) | A Sunday headline beside a daily chart would put two different "current" Body Ages on one screen, which AGENTS.md forbids. The 182-day window already gives the week-to-week stability the Sunday cadence was for. | | HRV | Shown on the tab as context, **not scored** | See gap 7. | | Model change on stored rows | Bump a `healthspan_model` version; re-derive the stored `body_age` / `pace_of_aging` history under the new model in one idempotent pass | Otherwise the trend chart would splice two models together at the upgrade date. | @@ -107,7 +107,7 @@ Changes inside `VitalityEngine` / `PaceOfAgingEngine`. Each curve carries its ci 5. **Trend:** Body Age and pace over 6 months (StrandDesign chart). 6. **Context (not scored):** HRV. 7. **Method sheet:** the referent choice, the limits, the references. -8. The **Resilience** section (sibling plan) mounts below, behind its Experimental toggle. +8. The **Resilience** section (sibling plan) mounts below, behind its Experimental toggle. *(Not in this PR: it ships with the Resilience plan.)* The same rule applies to every readout: hero, dial, chips and chart all read one stored point per day. Design tokens only. New copy must go into `Strand/Resources/Localizable.xcstrings` with de / es / fr /