Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Strand/Screens/ChargeBreakdownWiring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Strand/Screens/CoupledView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions StrandTests/ChargeBreakdownWiringTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
18 changes: 15 additions & 3 deletions android/app/src/main/java/com/noop/ui/TodayScoring.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,21 @@ internal fun recoveryCalibrationNights(
internal fun recoveryChargeDrivers(
days: List<DailyMetric>,
displayDay: DailyMetric?,
hrvBaselineEpoch: Double = 0.0,
): List<ChargeDriver> {
val day = displayDay ?: return emptyList()
val hrv = day.avgHrv ?: return emptyList()
val rhr = day.restingHr?.toDouble() ?: return emptyList()

// 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.
Expand Down Expand Up @@ -100,9 +107,14 @@ internal fun recoveryChargeDrivers(
internal fun chargeConfidenceTier(
days: List<DailyMetric>,
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)
}

Expand Down
8 changes: 6 additions & 2 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
54 changes: 54 additions & 0 deletions android/app/src/test/java/com/noop/ui/RecoveryDriversUiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<com.noop.analytics.ChargeDriver> ->
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)
}
}
Loading