diff --git a/Strand/Screens/ChargeBreakdownWiring.swift b/Strand/Screens/ChargeBreakdownWiring.swift index a5bfcb38b5..cc5eab7866 100644 --- a/Strand/Screens/ChargeBreakdownWiring.swift +++ b/Strand/Screens/ChargeBreakdownWiring.swift @@ -31,11 +31,17 @@ enum ChargeBreakdownWiring { /// reader looks first. static func breakdown(days: [DailyMetric], row: DailyMetric, - sleepPerfPercent: Double?) -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { + sleepPerfPercent: Double?, + hrvBaselineEpoch: Double = 0) -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { guard let hrv = row.avgHrv, let rhr = row.restingHr else { return nil } // PERF: one pass per series. The two private copies this replaces each re-folded the full history // per body evaluation of the open sheet; the guard above still runs before any fold. - let hrvBase = Baselines.foldHistory(days.map(\.avgHrv), cfg: Baselines.hrvCfg) + // #2315: fold with the recalibration epoch, exactly as the engine does. Without it these rows and + // the confidence tier scored against the WHOLE history while the headline scored against the + // post-Recalibrate nights, so the Charge page showed two baselines for one metric. `0` (no + // recalibration) delegates to the plain fold, so a user who never recalibrated sees no change. + let hrvBase = Baselines.foldHistory(days.map(\.avgHrv), dayKeys: days.map(\.day), + cfg: Baselines.hrvCfg, baselineEpoch: hrvBaselineEpoch) guard hrvBase.usable else { return nil } let rhrBase = Baselines.foldHistory(days.map { $0.restingHr.map(Double.init) }, cfg: Baselines.restingHRCfg) diff --git a/Strand/Screens/CoupledView.swift b/Strand/Screens/CoupledView.swift index a0575d43f5..2560118c7e 100644 --- a/Strand/Screens/CoupledView.swift +++ b/Strand/Screens/CoupledView.swift @@ -535,7 +535,8 @@ struct CoupledView: View { /// the open sheet; one call now folds each series exactly once, guards before any fold. private func chargeBreakdown() -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { guard let row = breakdownRow else { return nil } - return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: sleepPerformance) + return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: sleepPerformance, + hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) } @ViewBuilder diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index dc67eb45b2..27d2551200 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -832,7 +832,8 @@ struct TodayView: View { /// sheet reads drivers + confidence out of a single sheet-local `let`. private func chargeBreakdown() -> (drivers: [ChargeDriver], confidence: ScoreConfidence)? { guard let row = chargeBreakdownRow else { return nil } - return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: restScore) + return ChargeBreakdownWiring.breakdown(days: repo.days, row: row, sleepPerfPercent: restScore, + hrvBaselineEpoch: Baselines.hrvBaselineEpoch()) } /// The night's relative skin-temp marker for the displayed row (A5), or nil. Surfaced verbatim from @@ -1034,7 +1035,12 @@ struct TodayView: View { switch metricKey { case "recovery": // Same HRV-baseline gate the Charge engine uses, fed by the loaded nightly SDNN history. - let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), cfg: Baselines.hrvCfg) + // #2315: with the recalibration epoch, which is what makes the claim above true. Without it this + // gate folded the whole history while the Charge engine folded from the epoch, so the pill could + // read solid off nights the ring is no longer using. + let hrvBase = Baselines.foldHistory(repo.days.map(\.avgHrv), dayKeys: repo.days.map(\.day), + cfg: Baselines.hrvCfg, + baselineEpoch: Baselines.hrvBaselineEpoch()) conf = ScoreConfidence.charge(recovery: displayDay?.recovery, hrvBaseline: hrvBase) case "sleep_performance": // A watch night with a Rest score reads as built; without one it's still calibrating. diff --git a/StrandTests/ChargeBreakdownWiringTests.swift b/StrandTests/ChargeBreakdownWiringTests.swift index 2ee14cd409..fb038774ea 100644 --- a/StrandTests/ChargeBreakdownWiringTests.swift +++ b/StrandTests/ChargeBreakdownWiringTests.swift @@ -62,4 +62,54 @@ final class ChargeBreakdownWiringTests: XCTestCase { XCTAssertTrue(labels.contains("Resting heart rate"), "\(labels)") XCTAssertNotEqual(out?.confidence, .calibrating) } + + /// Epoch for a `yyyy-MM-dd` key at UTC midnight, matching what Recalibrate writes. + private func epochOf(_ day: String) -> Double { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(secondsFromGMT: 0)! + let parts = day.split(separator: "-").compactMap { Int($0) } + let date = cal.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2]))! + return date.timeIntervalSince1970 + } + + func testDriverBaselineHonoursTheRecalibrationEpoch() { + // The reported shape: low-HRV history, Recalibrate, then higher nights. Folding the WHOLE history + // gives one baseline and folding from the epoch gives another, and the headline uses the second. + // These rows have to agree with the headline rather than with history. + // + // Asserted on `baselineText` because that is what a Swift ChargeDriver carries: it pre-formats for + // display where the Kotlin twin keeps a numeric `baseline`. Different text is the observable proof + // that a different history was folded. + let old = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 40) } + let since = (11...20).map { day(String(format: "2026-01-%02d", $0), hrv: 70) } + let today = day("2026-01-21", hrv: 72, recovery: 70) + let days = old + since + [today] + + let whole = ChargeBreakdownWiring.breakdown(days: days, row: today, sleepPerfPercent: 85, + hrvBaselineEpoch: 0) + let fromEpoch = ChargeBreakdownWiring.breakdown(days: days, row: today, sleepPerfPercent: 85, + hrvBaselineEpoch: epochOf("2026-01-11")) + let hrvRow: ((drivers: [ChargeDriver], confidence: ScoreConfidence)?) -> ChargeDriver? = { out in + out?.drivers.first(where: { $0.label == "Heart rate variability" }) + } + guard let w = hrvRow(whole), let r = hrvRow(fromEpoch) else { + return XCTFail("both folds must produce an HRV row") + } + XCTAssertNotEqual(w.baselineText, r.baselineText, + "the epoch fold must discard the pre-recalibration nights, so the baseline it " + + "reports differs from the whole-history one (whole=\(w.baselineText), " + + "fromEpoch=\(r.baselineText))") + } + + func testAnAbsentEpochLeavesTheBaselineExactlyAsItWas() { + // The common case: nobody who never recalibrated may see any change from this. + let past = (1...10).map { day(String(format: "2026-01-%02d", $0), hrv: 50 + Double($0 % 3)) } + let today = day("2026-01-20", hrv: 62, rhr: 51, recovery: 64) + let implicit = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, sleepPerfPercent: 85) + let explicitZero = ChargeBreakdownWiring.breakdown(days: past + [today], row: today, + sleepPerfPercent: 85, hrvBaselineEpoch: 0) + XCTAssertEqual(implicit?.drivers.map(\.label), explicitZero?.drivers.map(\.label)) + XCTAssertEqual(implicit?.drivers.map(\.baselineText), explicitZero?.drivers.map(\.baselineText)) + XCTAssertEqual(implicit?.drivers.map(\.deltaPoints), explicitZero?.drivers.map(\.deltaPoints)) + } } diff --git a/android/app/src/main/java/com/noop/ui/TodayScoring.kt b/android/app/src/main/java/com/noop/ui/TodayScoring.kt index 0365a2a7d7..e92816d921 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -60,6 +60,7 @@ internal fun recoveryCalibrationNights( internal fun recoveryChargeDrivers( days: List, displayDay: DailyMetric?, + hrvBaselineEpoch: Double = 0.0, ): List { val day = displayDay ?: return emptyList() val hrv = day.avgHrv ?: return emptyList() @@ -67,7 +68,13 @@ internal fun recoveryChargeDrivers( // Whole-history fold (oldest first), exactly as the engine seeds baselines2. val ordered = days.sortedBy { it.day } - val hrvBase = Baselines.foldHistory(ordered.map { it.avgHrv }, Baselines.hrvCfg) + // #2315: fold with the recalibration epoch, exactly as the engine does. Without it these rows + // scored against the WHOLE history while the headline scored against the post-Recalibrate nights, + // so the Charge page showed two different baselines for the same metric. `0.0` (no recalibration) + // delegates to the plain fold, so a user who never recalibrated sees no change at all. + val hrvBase = Baselines.foldHistory( + ordered.map { it.avgHrv }, ordered.map { it.day }, Baselines.hrvCfg, hrvBaselineEpoch, + ) if (!hrvBase.usable) return emptyList() // Passed on ungated, unlike respBase below, and that is deliberate since #1988: chargeDrivers // gates this one itself, for its score AND for the row it builds from the baseline directly. @@ -100,9 +107,14 @@ internal fun recoveryChargeDrivers( internal fun chargeConfidenceTier( days: List, displayDay: DailyMetric?, + hrvBaselineEpoch: Double = 0.0, ): ScoreConfidence { - val hrvBase: BaselineState = - Baselines.foldHistory(days.sortedBy { it.day }.map { it.avgHrv }, Baselines.hrvCfg) + // #2315: epoch-aware for the same reason as the drivers above. The tier is read off the baseline + // the ring rides, so folding a different history here could badge a scored day as CALIBRATING. + val ordered = days.sortedBy { it.day } + val hrvBase: BaselineState = Baselines.foldHistory( + ordered.map { it.avgHrv }, ordered.map { it.day }, Baselines.hrvCfg, hrvBaselineEpoch, + ) return ScoreConfidence.forCharge(displayDay?.recovery, hrvBase) } diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 50c8ac8b63..9b89727650 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -5353,13 +5353,17 @@ private fun RecoveryDriversSection( displayDay: DailyMetric?, carriedDay: DailyMetric? = null, ) { + // #2315: the same recalibration epoch the engine folds with. Read here rather than threaded from the + // caller because this section is the only consumer, and the pref read is one getLong behind remember. + val context = LocalContext.current + val hrvEpoch = remember { NoopPrefs.of(context).getLong(Baselines.hrvBaselineEpochKey, 0L).toDouble() } // Read the row the Charge ring itself reads: today's own when scored, else the carried last-scored // day (#543) so the breakdown matches the carried ring instead of vanishing at the rollover. val readDay = carriedDay ?: displayDay - val drivers = remember(days, readDay) { recoveryChargeDrivers(days, readDay) } + val drivers = remember(days, readDay, hrvEpoch) { recoveryChargeDrivers(days, readDay, hrvEpoch) } if (drivers.isEmpty()) return - val tier = remember(days, readDay) { chargeConfidenceTier(days, readDay) } + val tier = remember(days, readDay, hrvEpoch) { chargeConfidenceTier(days, readDay, hrvEpoch) } val overline = carriedDay?.let { uiString(R.string.today_charge_carried, carriedCaption(it.day).localized()) } ?: uiString(R.string.trends_charge) diff --git a/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt index b140a547b6..d0768f3908 100644 --- a/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt +++ b/android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt @@ -95,4 +95,58 @@ class RecoveryDriversUiTest { assertFalse("no usable resting-HR baseline, so no RHR row: got $labels", labels.contains(ChargeDriverLabel.RESTING_HEART_RATE)) } + + /** Epoch for a `yyyy-MM-dd` day key, UTC midnight, matching what Recalibrate writes. */ + private fun epochOf(day: String): Double = + java.time.LocalDate.parse(day).atStartOfDay(java.time.ZoneOffset.UTC).toEpochSecond().toDouble() + + @Test fun driverBaselineHonoursTheRecalibrationEpoch() { + // #2315: the reported shape. A history of low-HRV nights, then Recalibrate, then higher nights. + // Folding the WHOLE history gives one baseline and folding from the epoch gives another, and the + // headline uses the second. These rows must agree with the headline rather than with history. + val old = (1..10).map { day("2026-01-%02d".format(it), hrv = 40.0) } + val since = (11..20).map { day("2026-01-%02d".format(it), hrv = 70.0) } + val today = day("2026-01-21", hrv = 72.0, recovery = 70.0) + val days = old + since + today + + val wholeHistory = recoveryChargeDrivers(days, today, hrvBaselineEpoch = 0.0) + val fromEpoch = recoveryChargeDrivers(days, today, hrvBaselineEpoch = epochOf("2026-01-11")) + + val hrvOf = { rows: List -> + rows.first { it.label == ChargeDriverLabel.HEART_RATE_VARIABILITY }.baseline + } + val whole = hrvOf(wholeHistory) + val recent = hrvOf(fromEpoch) + assertTrue("both folds must produce an HRV row", whole != null && recent != null) + assertTrue( + "the epoch fold must discard the pre-recalibration nights, so its baseline sits higher " + + "(whole=$whole, fromEpoch=$recent)", + recent!! > whole!!, + ) + } + + @Test fun anAbsentEpochLeavesTheBaselineExactlyAsItWas() { + // The common case: nobody who never recalibrated may see any change from this. + val days = scoredHistory() + val implicit = recoveryChargeDrivers(days, days.last()) + val explicitZero = recoveryChargeDrivers(days, days.last(), hrvBaselineEpoch = 0.0) + assertEquals(implicit.map { it.label }, explicitZero.map { it.label }) + assertEquals(implicit.map { it.baseline }, explicitZero.map { it.baseline }) + assertEquals(implicit.map { it.deltaPoints }, explicitZero.map { it.deltaPoints }) + } + + @Test fun theConfidenceTierUsesTheSameEpochAsTheDrivers() { + // The tier is read off the baseline the ring rides. A post-Recalibrate history that is still + // seeding must not be badged from a whole-history fold that looks fully trusted. + // Two nights since Recalibrate plus today is three, under the four-night seed gate, so the + // post-epoch baseline is not yet usable while the whole-history one looks fully trusted. + val old = (1..20).map { day("2026-01-%02d".format(it), hrv = 45.0) } + val since = (21..22).map { day("2026-01-%02d".format(it), hrv = 60.0) } + val today = day("2026-01-23", hrv = 61.0, recovery = 66.0) + val days = old + since + today + val whole = chargeConfidenceTier(days, today, hrvBaselineEpoch = 0.0) + val fromEpoch = chargeConfidenceTier(days, today, hrvBaselineEpoch = epochOf("2026-01-21")) + assertEquals(ScoreConfidence.CALIBRATING, fromEpoch) + assertFalse("the whole-history fold is what made these disagree", whole == fromEpoch) + } }