From b47b26e2325e655995cf37904cf946ec3adac430 Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:25:14 -0500 Subject: [PATCH 1/4] feat(sleep): recalculate a selected past wake date Refs #515 --- .../StrandAnalytics/AnalyticsEngine.swift | 26 +- .../SleepRecalculationDependents.swift | 46 ++ .../SleepRecalculationWindow.swift | 32 ++ .../SleepRecalculationDependentsTests.swift | 62 +++ .../SleepRecalculationWindowTests.swift | 84 ++++ .../Sources/WhoopStore/MetricsCache.swift | 111 ++--- .../ScoreInputProvenanceStore.swift | 60 ++- .../WhoopStore/SleepRecoveryUpdate.swift | 19 + .../SelectedSleepScoreTransactionTests.swift | 171 ++++++++ Strand/Data/IntelligenceEngine.swift | 398 +++++++++++++----- Strand/Resources/Localizable.xcstrings | 10 + Strand/Screens/SleepView.swift | 74 +++- Tools/parity_dispositions.json | 152 +++++++ Tools/parity_twin_map.json | 16 +- .../com/noop/analytics/AnalyticsEngine.kt | 23 +- .../com/noop/analytics/IntelligenceEngine.kt | 182 ++++++-- .../noop/analytics/IntelligencePersistence.kt | 26 +- .../analytics/SleepRecalculationDependents.kt | 45 ++ .../analytics/SleepRecalculationWindow.kt | 30 ++ .../java/com/noop/data/SleepRecoveryUpdate.kt | 8 + .../src/main/java/com/noop/data/WhoopDao.kt | 8 + .../java/com/noop/data/WhoopRepository.kt | 68 ++- .../src/main/java/com/noop/ui/AppViewModel.kt | 38 +- .../com/noop/ui/SelectedSleepRecalculation.kt | 37 ++ .../src/main/java/com/noop/ui/SleepScreen.kt | 73 +++- .../app/src/main/res/values-de/strings.xml | 10 + .../app/src/main/res/values-es/strings.xml | 10 + .../app/src/main/res/values-fr/strings.xml | 10 + .../app/src/main/res/values-it/strings.xml | 10 + .../app/src/main/res/values-pl/strings.xml | 10 + .../src/main/res/values-pt-rPT/strings.xml | 10 + .../app/src/main/res/values-ru/strings.xml | 10 + .../app/src/main/res/values-zh/strings.xml | 10 + android/app/src/main/res/values/strings.xml | 10 + .../IntelligenceEngineJacocoBudgetTest.kt | 19 +- .../SleepRecalculationDependentsTest.kt | 44 ++ .../analytics/SleepRecalculationWindowTest.kt | 76 ++++ .../data/SelectedSleepScoreTransactionTest.kt | 302 +++++++++++++ .../noop/ui/SelectedSleepRecalculationTest.kt | 72 ++++ 39 files changed, 2164 insertions(+), 238 deletions(-) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationWindow.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationDependentsTests.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationWindowTests.swift create mode 100644 Packages/WhoopStore/Sources/WhoopStore/SleepRecoveryUpdate.swift create mode 100644 Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift create mode 100644 android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt create mode 100644 android/app/src/main/java/com/noop/analytics/SleepRecalculationWindow.kt create mode 100644 android/app/src/main/java/com/noop/data/SleepRecoveryUpdate.kt create mode 100644 android/app/src/main/java/com/noop/ui/SelectedSleepRecalculation.kt create mode 100644 android/app/src/test/java/com/noop/analytics/SleepRecalculationDependentsTest.kt create mode 100644 android/app/src/test/java/com/noop/analytics/SleepRecalculationWindowTest.kt create mode 100644 android/app/src/test/java/com/noop/data/SelectedSleepScoreTransactionTest.kt create mode 100644 android/app/src/test/java/com/noop/ui/SelectedSleepRecalculationTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 1bcc91790e..8eadb95e1c 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -314,6 +314,7 @@ public enum AnalyticsEngine { /// and low enough to keep a partially-drained night. Twin of the Kotlin constant. public static let vendorRespMinSpanS = 3_600 + /// Kotlin twin: `AnalyticsEngine.analyzeDay`. public static func analyzeDay(day: String, // Optional sink for the Effort funnel line. Nil (the default) builds // nothing at all — see StrainScorer.strain. A parameter rather than a @@ -491,7 +492,9 @@ public enum AnalyticsEngine { // %HRR with no floor. Threaded rather than read from a global so this // stays a pure function, and defaulted so every existing caller and // test is byte-identical. - effortMethod: StrainScorer.Method = .edwards) -> DayResult { + effortMethod: StrainScorer.Method = .edwards, + sleepDayWindow: SleepRecalculationWindow? = nil, + preservedSleep: [CachedSleepSession] = []) -> DayResult { // Precompute the day's UTC bounds ONCE (#996). `dayString(ts, offsetSec:)` formats the UTC // calendar day of (ts + offset) with a FIXED offset, so "== day" is exactly membership in @@ -501,7 +504,13 @@ public enum AnalyticsEngine { // formatter compare (locked by AnalyticsEngineDayBoundsTests, incl. fractional offsets). let dayStartUtc = dayStartUtcSeconds(day) let dayEndUtc = dayStartUtc + 86_400 - func tsInDay(_ ts: Int) -> Bool { (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc } + func tsInDay(_ ts: Int) -> Bool { + if let window = sleepDayWindow { + precondition(window.day == day) + return window.contains(ts) + } + return (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc + } // ── Sleep detection + staging ───────────────────────────────────────── let detectedSessions = SleepStager.detectSleep(hr: hr, rr: rr, resp: resp, gravity: gravity, @@ -514,20 +523,27 @@ public enum AnalyticsEngine { // night-window stream the caller passed for the rest of this analysis; the pass self-gates on its // observed density, so an empty/sparse `steps` (e.g. a WHOOP 4.0, which never emits StepSample at // all) is a no-op regardless of `useMotionAwareWake`. - let refinedSessions = useMotionAwareWake + let refined = useMotionAwareWake ? detectedSessions.map { WakeMotionRefinement.refine($0, grav: gravity, steps: steps) } : detectedSessions + let refinedSessions = refined.filter { detected in + !preservedSleep.contains { $0.effectiveStartTs < detected.end && detected.start < $0.endTs } + } + let protectedProvided = preservedSleep.compactMap { sleepSession(fromProvided: $0) } + let allProvidedSleep = providedSleep.filter { provided in + !preservedSleep.contains { $0.effectiveStartTs < provided.end && provided.start < $0.endTs } + } + protectedProvided // #804 Fix A: fold in the caller's device-provided hypnogram (see `providedSleep`). Empty = the // byte-identical motion-only path. Otherwise enrich each provided session's nightly restingHR/avgHRV // from THIS day's hr/rr over its window (the stored ring row carries neither), using the SAME helpers // detectSleep populates a session with, then keep only the detected sessions that DON'T overlap a // provided one (provided is authoritative where they collide; a separate nap survives). let allSessions: [SleepSession] - if providedSleep.isEmpty { + if allProvidedSleep.isEmpty { allSessions = refinedSessions } else { let rrSorted = rr.sortedByTsStable() - let enrichedProvided: [SleepSession] = providedSleep.map { s in + let enrichedProvided: [SleepSession] = allProvidedSleep.map { s in // #1884: no HR-only special case any more. This used to short-circuit on `s.hrOnly` to // preserve #1801's display-only guarantee, which withheld both values. Now that an HR-only // session reports what it measured, that clause is not merely redundant — an HR-only night diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift new file mode 100644 index 0000000000..32058ebde8 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift @@ -0,0 +1,46 @@ +import Foundation +import WhoopStore + +/// Refresh only Charge cells whose existing 21-day baseline can include the selected night. +/// Uses persisted inputs and the same baseline/Recovery/Rest functions as the scorer; no detection. +public enum SleepRecalculationDependents { + public static func updates(selectedDay: String, anchorDay: String, refreshBaseline: Bool, imported: [DailyMetric], computed: [DailyMetric], + sourceByDay: [String: String], importedSource: String, + hrvEpoch: Double, recoveryEpoch: Double) -> [SleepRecoveryUpdate] { + guard let selected = Baselines.isoEpochDay(selectedDay), let anchor = Baselines.isoEpochDay(anchorDay), + anchor >= selected, anchor - selected < ChargeBaselines.windowDays else { return [] } + var updates: [SleepRecoveryUpdate] = [] + for row in computed.sorted(by: { $0.day < $1.day }) { + guard let day = Baselines.isoEpochDay(row.day), day <= anchor, + anchor - day < ChargeBaselines.windowDays, + refreshBaseline || row.day == selectedDay, + let hrv = row.avgHrv, let rhr = row.restingHr else { continue } + let resolved = ChargeBaselines.resolve(imported: imported, own: computed, anchorDay: anchorDay, + hrvEpoch: hrvEpoch, recoveryEpoch: recoveryEpoch) + let sourceDays = Set((imported + computed).filter { $0.day <= anchorDay }.map(\.day)).sorted() + let respEpoch = max(recoveryEpoch, Baselines.deviceEraEpoch( + sourceDays.map { (day: $0, sourceId: sourceByDay[$0] ?? importedSource) })) + let respBase = Baselines.foldHistory(resolved.respHistory.values, dayKeys: resolved.respHistory.dayKeys, + cfg: Baselines.respCfg, baselineEpoch: respEpoch) + let skinCfg = Baselines.metricCfg["skin_temp"]! + let skinHistory = ChargeBaselines.history(imported: [], own: computed.map { ($0.day, $0.skinTempC) }, + anchorDay: anchorDay, cfg: skinCfg, baselineEpoch: recoveryEpoch) + let skinBase = Baselines.foldHistory(skinHistory.values, dayKeys: skinHistory.dayKeys, + cfg: skinCfg, baselineEpoch: recoveryEpoch) + let skinDev: Double? + if let mean = row.skinTempC { + skinDev = skinBase.usable ? (Baselines.deviation(mean, state: skinBase).delta * 100).rounded() / 100 : nil + } else { + skinDev = row.skinTempDevC // no raw mean: retain the legacy deviation + } + let restQuality = AnalyticsEngine.Rest.composite(daily: row).map { $0 / 100 } ?? row.efficiency + let recovery = resolved.hrv.usable ? RecoveryScorer.recovery( + hrv: hrv, rhr: Double(rhr), resp: row.respRateBpm, + hrvBaseline: resolved.hrv, rhrBaseline: resolved.restingHR.usable ? resolved.restingHR : nil, + respBaseline: respBase.usable ? respBase : nil, sleepPerf: restQuality, skinTempDev: skinDev) : nil + let update = SleepRecoveryUpdate(day: row.day, recovery: recovery, skinTempDevC: skinDev, sourceId: sourceByDay[row.day]) + if update.changes(row) { updates.append(update) } + } + return updates + } +} diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationWindow.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationWindow.swift new file mode 100644 index 0000000000..459303be83 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationWindow.swift @@ -0,0 +1,32 @@ +import Foundation + +/// A completed local wake day selected explicitly, independent of the normal recent scan. +/// Calendar midnights preserve 23/25-hour days; the read includes the scorer's neighboring context. +public struct SleepRecalculationWindow: Equatable, Sendable { + public let day: String + public let start: Int + public let end: Int + public let offsetSeconds: Int + public var readStart: Int { start - StreamReadCap.lookbackSeconds } + + public func contains(_ timestamp: Int) -> Bool { + timestamp >= start && timestamp < end + } + + public static func make(day: String, timeZone: TimeZone, now: Int) -> Self? { + let parts = day.split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2, + let year = Int(parts[0]), let month = Int(parts[1]), let date = Int(parts[2]) else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + guard let midnight = calendar.date(from: DateComponents(year: year, month: month, day: date)), + calendar.component(.year, from: midnight) == year, + calendar.component(.month, from: midnight) == month, + calendar.component(.day, from: midnight) == date, + let next = calendar.date(byAdding: .day, value: 1, to: midnight) else { return nil } + let start = Int(midnight.timeIntervalSince1970) + let end = Int(next.timeIntervalSince1970) + guard start < end, end <= now else { return nil } + return Self(day: day, start: start, end: end, offsetSeconds: timeZone.secondsFromGMT(for: midnight)) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationDependentsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationDependentsTests.swift new file mode 100644 index 0000000000..90dbe2dfab --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationDependentsTests.swift @@ -0,0 +1,62 @@ +import XCTest +import WhoopStore +@testable import StrandAnalytics + +final class SleepRecalculationDependentsTests: XCTestCase { + private func rows(hrv: Double) -> [DailyMetric] { + var result: [DailyMetric] = [] + for i in 0..<31 { + let day = String(format: "2026-07-%02d", i + 1) + let value: Double = i == 19 ? hrv : Double(45 + (i % 7) * 3) + let resp: Double = 15.0 + Double(i % 4) * 0.2 + let skin: Double = 33.0 + Double(i % 3) * 0.1 + result.append(DailyMetric(day: day, totalSleepMin: 420, efficiency: 0.9, + deepMin: 90, remMin: 100, lightMin: 230, disturbances: 1, restingHr: 48 + i % 5, + avgHrv: value, recovery: -1, strain: 10, exerciseCount: 0, respRateBpm: resp, skinTempC: skin)) + } + return result + } + + private func updates(hrv: Double, refresh: Bool, selected: String = "2026-07-20") -> [SleepRecoveryUpdate] { + SleepRecalculationDependents.updates(selectedDay: selected, anchorDay: "2026-07-31", refreshBaseline: refresh, + imported: [], computed: rows(hrv: hrv), sourceByDay: [:], importedSource: "my-whoop", + hrvEpoch: 0, recoveryEpoch: 0) + } + + func testOnlyTheExistingRecentBaselineWindowCanChange() { + let result = updates(hrv: 80, refresh: true) + XCTAssertEqual(result.map(\.day), (11...31).map { String(format: "2026-07-%02d", $0) }) + XCTAssertTrue(updates(hrv: 80, refresh: true, selected: "2026-07-01").isEmpty) + XCTAssertTrue(updates(hrv: 80, refresh: true, selected: "2026-08-01").isEmpty) + } + + func testUnchangedBaselineInputsRefreshOnlySelectedCharge() { + XCTAssertEqual(updates(hrv: 80, refresh: false).map(\.day), ["2026-07-20"]) + XCTAssertNotEqual(updates(hrv: 20, refresh: true), updates(hrv: 120, refresh: true)) + } + + func testSwiftOracleForKotlin() { + var actual: [String] = [] + for hrv in [20.0, 40.0, 80.0, 120.0] { + for refresh in [false, true] { + let values = updates(hrv: hrv, refresh: refresh).map { + "\($0.day):\($0.recovery.map(String.init(describing:)) ?? "nil"):\($0.skinTempDevC.map(String.init(describing:)) ?? "nil")" + }.joined(separator: ";") + actual.append("\(Int(hrv))|\(refresh)|\(values)") + XCTAssertFalse(values.isEmpty) + } + } + let expected = """ + 20|false|2026-07-20:1.0898008256514333:-0.01 + 20|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:1.0898008256514333:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11 + 40|false|2026-07-20:15.33364258561113:-0.01 + 40|true|2026-07-11:66.37246191295942:-0.01;2026-07-12:71.44762686950502:0.09;2026-07-13:77.92785705946825:-0.11;2026-07-14:81.95282906131843:-0.01;2026-07-15:26.347259541600412:0.09;2026-07-16:46.32062075209378:-0.11;2026-07-17:55.141780234075696:-0.01;2026-07-18:60.913758871911384:0.09;2026-07-19:66.50266795966225:-0.11;2026-07-20:15.33364258561113:-0.01;2026-07-21:87.16217085712255:0.09;2026-07-22:34.955910548564134:-0.11;2026-07-23:40.87125797089343:-0.01;2026-07-24:46.70472487954506:0.09;2026-07-25:55.28617637957595:-0.11;2026-07-26:75.07086969378537:-0.01;2026-07-27:79.2437855008177:0.09;2026-07-28:82.94551899534244:-0.11;2026-07-29:30.093990149917868:-0.01;2026-07-30:35.307761470393714:0.09;2026-07-31:56.83241462753154:-0.11 + 80|false|2026-07-20:97.38816204857353:-0.01 + 80|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:97.38816204857353:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11 + 120|false|2026-07-20:99.98810350209193:-0.01 + 120|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:99.98810350209193:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11 + """ + XCTAssertEqual(actual.joined(separator: "\n"), expected) + + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationWindowTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationWindowTests.swift new file mode 100644 index 0000000000..4985fef419 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepRecalculationWindowTests.swift @@ -0,0 +1,84 @@ +import XCTest +import WhoopProtocol +import WhoopStore +@testable import StrandAnalytics + +final class SleepRecalculationWindowTests: XCTestCase { + private let now = 1_800_000_000 + + func testCalendarWakeDaysIncludeBothDSTLengthsAndNeighborContext() throws { + for (day, hours) in [("2026-03-08", 23), ("2026-11-01", 25), ("2021-06-15", 24)] { + let window = try XCTUnwrap(SleepRecalculationWindow.make( + day: day, timeZone: TimeZone(identifier: "America/Chicago")!, now: now)) + XCTAssertEqual(window.end - window.start, hours * 3600) + XCTAssertEqual(window.readStart, window.start - StreamReadCap.lookbackSeconds) + XCTAssertFalse(window.contains(window.start - 1)) + XCTAssertTrue(window.contains(window.start)) + XCTAssertTrue(window.contains(window.end - 1)) + XCTAssertFalse(window.contains(window.end)) + } + } + + func testRejectsInvalidAndUnfinishedDatesWithoutRollingThem() throws { + let zone = TimeZone(secondsFromGMT: 0)! + for day in ["2026-02-30", "2026-13-01", "2026-1-01", "not-a-day"] { + XCTAssertNil(SleepRecalculationWindow.make(day: day, timeZone: zone, now: now)) + } + let window = try XCTUnwrap(SleepRecalculationWindow.make(day: "2021-06-15", timeZone: zone, now: now)) + XCTAssertNil(SleepRecalculationWindow.make(day: window.day, timeZone: zone, now: window.end - 1)) + XCTAssertNotNil(SleepRecalculationWindow.make(day: window.day, timeZone: zone, now: window.end)) + } + + func testSelectedOldMissingNightRunsRealDetectorFromInputs() throws { + let window = try XCTUnwrap(SleepRecalculationWindow.make( + day: "2021-06-15", timeZone: TimeZone(secondsFromGMT: 0)!, now: now)) + let end = window.start + 6 * 3600 + let start = end - 7 * 3600 + let hr = (start.. Int { try syncWrite { db in - var n = 0 - for s in sessions { - if !s.userEdited, let existing = try Self.storedSleepSession(db, deviceId: deviceId, startTs: s.startTs), - !existing.userEdited { - let candidate = CachedSleepSession(startTs: s.startTs, endTs: s.endTs, efficiency: nil, - restingHr: nil, avgHrv: nil, stagesJSON: s.stagesJSON) - if SleepMerge.richness(candidate) < SleepMerge.richness(existing) { - continue // a less-complete re-serve of this night must never clobber the stored one + try Self.writeSleepSessions(sessions, deviceId: deviceId, in: db) + } + } + + /// Shared SQL for ordinary upserts and the atomic selected-day scoring transaction. + /// Kotlin twin: `WhoopRepository.writeSleepSessions`. + static func writeSleepSessions(_ sessions: [CachedSleepSession], deviceId: String, in db: Database, + rejectIncompleteReplacement: Bool = false) throws -> Int { + var n = 0 + for s in sessions { + if !s.userEdited, let existing = try Self.storedSleepSession(db, deviceId: deviceId, startTs: s.startTs), + !existing.userEdited { + let candidate = CachedSleepSession(startTs: s.startTs, endTs: s.endTs, efficiency: nil, + restingHr: nil, avgHrv: nil, stagesJSON: s.stagesJSON) + if SleepMerge.richness(candidate) < SleepMerge.richness(existing) { + if rejectIncompleteReplacement { + throw NSError(domain: "SleepRecalculation", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Available inputs produced a less complete night."]) } + continue // a less-complete re-serve of this night must never clobber the stored one } - try db.execute(sql: """ - INSERT INTO sleepSession - (deviceId, startTs, endTs, efficiency, restingHr, avgHrv, stagesJSON, - userEdited, startTsAdjusted, stagingSparse) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(deviceId, startTs) DO UPDATE SET - -- A user-corrected night keeps its hand-set bed/wake times and stage breakdown; - -- a recompute/import refresh (this path) updates only the derived vitals. The - -- `userEdited` flag is preserved, never cleared here, so a later strap re-sync - -- can't revert a correction (the dedicated edit path is `applySleepEdit`). - endTs = CASE WHEN sleepSession.userEdited THEN sleepSession.endTs ELSE excluded.endTs END, - efficiency = excluded.efficiency, - restingHr = excluded.restingHr, - avgHrv = excluded.avgHrv, - stagesJSON = CASE WHEN sleepSession.userEdited THEN sleepSession.stagesJSON ELSE excluded.stagesJSON END, - startTsAdjusted = CASE WHEN sleepSession.userEdited THEN sleepSession.startTsAdjusted ELSE excluded.startTsAdjusted END, - userEdited = sleepSession.userEdited, - -- Derived from the day's motion coverage, so a recompute always refreshes it. - stagingSparse = excluded.stagingSparse - """, arguments: [deviceId, s.startTs, s.endTs, s.efficiency, - s.restingHr, s.avgHrv, s.stagesJSON, s.userEdited, s.startTsAdjusted, - s.stagingSparse]) - n += db.changesCount } - return n + try db.execute(sql: """ + INSERT INTO sleepSession + (deviceId, startTs, endTs, efficiency, restingHr, avgHrv, stagesJSON, + userEdited, startTsAdjusted, stagingSparse) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(deviceId, startTs) DO UPDATE SET + -- A user-corrected night keeps its hand-set bed/wake times and stage breakdown; + -- a recompute/import refresh (this path) updates only the derived vitals. The + -- `userEdited` flag is preserved, never cleared here, so a later strap re-sync + -- can't revert a correction (the dedicated edit path is `applySleepEdit`). + endTs = CASE WHEN sleepSession.userEdited THEN sleepSession.endTs ELSE excluded.endTs END, + efficiency = excluded.efficiency, + restingHr = excluded.restingHr, + avgHrv = excluded.avgHrv, + stagesJSON = CASE WHEN sleepSession.userEdited THEN sleepSession.stagesJSON ELSE excluded.stagesJSON END, + startTsAdjusted = CASE WHEN sleepSession.userEdited THEN sleepSession.startTsAdjusted ELSE excluded.startTsAdjusted END, + userEdited = sleepSession.userEdited, + -- Derived from the day's motion coverage, so a recompute always refreshes it. + stagingSparse = excluded.stagingSparse + """, arguments: [deviceId, s.startTs, s.endTs, s.efficiency, + s.restingHr, s.avgHrv, s.stagesJSON, s.userEdited, s.startTsAdjusted, + s.stagingSparse]) + n += db.changesCount } + return n } /// The stored row at (deviceId, startTs), or nil when there is none yet — the completeness check @@ -618,25 +629,29 @@ extension WhoopStore { /// Cached sleep sessions overlapping [from, to] (by startTs), oldest first. public func sleepSessions(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [CachedSleepSession] { try syncRead { db in - try Row.fetchAll(db, sql: """ - SELECT startTs, endTs, efficiency, restingHr, avgHrv, stagesJSON, userEdited, - startTsAdjusted, stagingSparse FROM sleepSession - WHERE deviceId = ? AND startTs >= ? AND startTs <= ? - ORDER BY startTs ASC LIMIT ? - """, arguments: [deviceId, from, to, limit]) - .map { - CachedSleepSession(startTs: $0["startTs"], endTs: $0["endTs"], - efficiency: $0["efficiency"], restingHr: $0["restingHr"], - avgHrv: $0["avgHrv"], stagesJSON: $0["stagesJSON"], - userEdited: $0["userEdited"], startTsAdjusted: $0["startTsAdjusted"], - stagingSparse: $0["stagingSparse"], - // The read knows the device it queried, so every block it hands - // back carries it and no caller has to ask the store again. - deviceId: deviceId) - } + try Self.readSleepSessions(deviceId: deviceId, from: from, to: to, limit: limit, in: db) } } + /// Shared selected-transaction read; Kotlin twin: `WhoopDao.sleepSessions`. + static func readSleepSessions(deviceId: String, from: Int, to: Int, limit: Int, in db: Database) throws -> [CachedSleepSession] { + try Row.fetchAll(db, sql: """ + SELECT startTs, endTs, efficiency, restingHr, avgHrv, stagesJSON, userEdited, + startTsAdjusted, stagingSparse FROM sleepSession + WHERE deviceId = ? AND startTs >= ? AND startTs <= ? + ORDER BY startTs ASC LIMIT ? + """, arguments: [deviceId, from, to, limit]) + .map { + CachedSleepSession(startTs: $0["startTs"], endTs: $0["endTs"], + efficiency: $0["efficiency"], restingHr: $0["restingHr"], + avgHrv: $0["avgHrv"], stagesJSON: $0["stagesJSON"], + userEdited: $0["userEdited"], startTsAdjusted: $0["startTsAdjusted"], + stagingSparse: $0["stagingSparse"], + // The read knows the device it queried, so every block it hands + // back carries it and no caller has to ask the store again. + deviceId: deviceId) + } } + /// Cached daily metrics for days in [from, to] (lexicographic YYYY-MM-DD compare), oldest first. public func dailyMetrics(deviceId: String, from: String, to: String) async throws -> [DailyMetric] { try syncRead { db in diff --git a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift index ce0431767c..a8de59ed1f 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift @@ -46,7 +46,11 @@ extension WhoopStore { to: String, replaceMetricKeys: [String] = [], additionalMetricPoints: [SourcedMetricPoint] = [], - replaceMetricSourceIds: [String] = [] + replaceMetricSourceIds: [String] = [], + sleepSessions: [CachedSleepSession]? = nil, + dependentRecoveryUpdates: [SleepRecoveryUpdate] = [], + sleepMotion: [Int: [Double]] = [:], sleepStates: [Int: [Int]] = [:], + selectedSleepBounds: ClosedRange? = nil ) async throws { // #1196: an empty scoring pass must not destructively rewrite the window — with no daily rows to // write, the provenance wide-delete below would blank the window's attribution while a degenerate @@ -54,7 +58,18 @@ extension WhoopStore { // nothing. A real pass always carries the days it scored, so this guard never fires in steady state. // Twin of the Android WhoopDao.replaceComputedScoreWindow empty guard. guard !dailyMetrics.isEmpty else { return } + if sleepSessions != nil { try Task.checkCancellation() } + if selectedSleepBounds != nil && !dailyMetrics.allSatisfy({ (from...to).contains($0.day) }) { + throw NSError(domain: "WhoopStore.selectedSleepScope", code: 1) + } + let scopedPoints = selectedSleepBounds == nil ? metricPoints : metricPoints.filter { (from...to).contains($0.day) } + let scopedAdditional = selectedSleepBounds == nil ? additionalMetricPoints : additionalMetricPoints.filter { (from...to).contains($0.point.day) } + let scopedProvenance = selectedSleepBounds == nil ? provenance : provenance.filter { (from...to).contains($0.day) } try syncWrite { db in + if let sleepSessions { + try Task.checkCancellation() + _ = try Self.writeSleepSessions(sleepSessions, deviceId: deviceId, in: db, rejectIncompleteReplacement: true) + } _ = try Self.upsertDailyMetrics(dailyMetrics, deviceId: deviceId, in: db) for source in Set(replaceMetricSourceIds + [deviceId]) { for key in replaceMetricKeys { @@ -62,8 +77,8 @@ extension WhoopStore { arguments: [source, key, from, to]) } } - _ = try Self.upsertMetricSeries(metricPoints, deviceId: deviceId, in: db) - for group in Dictionary(grouping: additionalMetricPoints, by: \.deviceId) { + _ = try Self.upsertMetricSeries(scopedPoints, deviceId: deviceId, in: db) + for group in Dictionary(grouping: scopedAdditional, by: \.deviceId) { _ = try Self.upsertMetricSeries(group.value.map(\.point), deviceId: group.key, in: db) } @@ -74,13 +89,50 @@ extension WhoopStore { DELETE FROM scoreInputProvenance WHERE deviceId = ? AND day >= ? AND day <= ? AND key != 'vo2max_est' """, arguments: [deviceId, from, to]) - for row in provenance { + for row in scopedProvenance { try db.execute(sql: """ INSERT INTO scoreInputProvenance (deviceId, day, key, sourceId) VALUES (?, ?, ?, ?) ON CONFLICT(deviceId, day,key) DO UPDATE SET sourceId = excluded.sourceId """, arguments: [deviceId, row.day, row.key, row.sourceId]) } + for update in dependentRecoveryUpdates { + try Self.writeSleepDependentRecovery(update, deviceId: deviceId, in: db) + } + if let sleepSessions { + let freshStarts = Set(sleepSessions.map(\.startTs)) + for (start, values) in sleepMotion where freshStarts.contains(start) { + try db.execute(sql: "UPDATE sleepSession SET motionJSON = ? WHERE deviceId = ? AND startTs = ?", + arguments: [Self.encodeDoubleArray(values), deviceId, start]) + } + for (start, values) in sleepStates where freshStarts.contains(start) { + try db.execute(sql: "UPDATE sleepSession SET sleepStateJSON = ? WHERE deviceId = ? AND startTs = ?", + arguments: [Self.encodeIntArray(values), deviceId, start]) + } + if let bounds = selectedSleepBounds, !sleepSessions.isEmpty { + precondition(deviceId.hasSuffix("-noop")) + let stored = try Self.readSleepSessions(deviceId: deviceId, from: bounds.lowerBound - 30 * 3600, + to: bounds.upperBound, limit: 4000, in: db).filter { bounds.contains($0.endTs) } + for stale in SleepSessionDedup.dedupe(stored, freshStarts: freshStarts).dropped { + try db.execute(sql: "DELETE FROM sleepSession WHERE deviceId = ? AND startTs = ?", + arguments: [deviceId, stale.startTs]) + } + } + try Task.checkCancellation() + } + } + } + + /// Kotlin twin: `WhoopDao.updateSleepDependentRecovery`. + static func writeSleepDependentRecovery(_ update: SleepRecoveryUpdate, deviceId: String, in db: Database) throws { + try db.execute(sql: "UPDATE dailyMetric SET recovery = ?, skinTempDevC = ? WHERE deviceId = ? AND day = ?", + arguments: [update.recovery, update.skinTempDevC, deviceId, update.day]) + if update.recovery == nil { + try db.execute(sql: "DELETE FROM scoreInputProvenance WHERE deviceId = ? AND day = ? AND key = 'recovery'", + arguments: [deviceId, update.day]) + } else if let source = update.sourceId { + try db.execute(sql: "INSERT INTO scoreInputProvenance (deviceId, day, key, sourceId) VALUES (?, ?, 'recovery', ?) ON CONFLICT(deviceId, day, key) DO UPDATE SET sourceId = excluded.sourceId", + arguments: [deviceId, update.day, source]) } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/SleepRecoveryUpdate.swift b/Packages/WhoopStore/Sources/WhoopStore/SleepRecoveryUpdate.swift new file mode 100644 index 0000000000..adf310d8dc --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/SleepRecoveryUpdate.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A bounded derived-score update; sleep records and other stored day fields remain unchanged. +public struct SleepRecoveryUpdate: Equatable, Sendable { + public let day: String + public let recovery: Double? + public let skinTempDevC: Double? + public let sourceId: String? + + public init(day: String, recovery: Double?, skinTempDevC: Double?, sourceId: String? = nil) { + self.day = day + self.recovery = recovery + self.skinTempDevC = skinTempDevC + self.sourceId = sourceId + } + public func changes(_ row: DailyMetric) -> Bool { + day == row.day && (recovery != row.recovery || skinTempDevC != row.skinTempDevC) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift new file mode 100644 index 0000000000..002aac6837 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift @@ -0,0 +1,171 @@ +import XCTest +@testable import WhoopStore + +final class SelectedSleepScoreTransactionTests: XCTestCase { + private let source = "my-whoop-noop" + private let day = "2021-06-15" + + private func sleep(_ start: Int, _ end: Int, edited: Bool = false) -> CachedSleepSession { + CachedSleepSession(startTs: start, endTs: end, efficiency: 0.9, restingHr: 50, avgHrv: 60, + stagesJSON: "[{\"start\":\(start),\"end\":\(end),\"stage\":\"deep\"}]", userEdited: edited) + } + + private func daily(_ recovery: Double) -> DailyMetric { + DailyMetric(day: day, totalSleepMin: 420, efficiency: 0.9, deepMin: 420, remMin: 0, lightMin: 0, + disturbances: 0, restingHr: 50, avgHrv: 60, recovery: recovery, strain: 10, exerciseCount: 0) + } + + private func commit(_ store: WhoopStore, sleeps: [CachedSleepSession], recovery: Double = 70) async throws { + try await store.persistComputedScores(dailyMetrics: [daily(recovery)], + metricPoints: [.init(day: day, key: "sleep_performance", value: 80)], + provenance: [.init(day: day, key: "recovery", sourceId: "active-strap")], + deviceId: source, from: day, to: day, sleepSessions: sleeps) + } + + func testMissingOldNightBanksWithScoresAndPreservesOtherSourcesAndRows() async throws { + let store = try await WhoopStore.inMemory() + let selected = sleep(1_623_711_600, 1_623_736_800) + let neighboring = sleep(selected.startTs - 86400, selected.endTs - 86400) + let manual = sleep(selected.endTs + 3600, selected.endTs + 7200, edited: true) + _ = try await store.upsertSleepSessions([neighboring, manual], deviceId: source) + _ = try await store.upsertSleepSessions([selected], deviceId: "my-whoop") + let before = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + let imported = try await store.sleepSessions(deviceId: "my-whoop", from: 0, to: 2_000_000_000, limit: 100) + try await commit(store, sleeps: [selected]) + let after = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + XCTAssertEqual(after.filter { $0.startTs != selected.startTs }, before) + let afterImported = try await store.sleepSessions(deviceId: "my-whoop", from: 0, to: 2_000_000_000, limit: 100) + XCTAssertEqual(afterImported, imported) + XCTAssertEqual(after.first { $0.startTs == selected.startTs }?.endTs, selected.endTs) + let scores = try await store.dailyMetrics(deviceId: source, from: day, to: day) + let provenance = try await store.scoreInputSource(deviceId: source, day: day, key: "recovery") + XCTAssertEqual(scores.first?.totalSleepMin, 420) + XCTAssertEqual(provenance, "active-strap") + } + + func testIncompleteDetectedNightRefreshesWithoutChangingStableKey() async throws { + let store = try await WhoopStore.inMemory() + let complete = sleep(1_623_711_600, 1_623_736_800) + _ = try await store.upsertSleepSessions([sleep(complete.startTs, complete.startTs + 5400)], deviceId: source) + try await commit(store, sleeps: [complete]) + let rows = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.endTs, complete.endTs) + XCTAssertEqual(rows.first?.stagesJSON, complete.stagesJSON) + } + + func testFailedDailyWriteRollsBackSleepAndProvenanceTogether() async throws { + let store = try await WhoopStore.inMemory() + try await commit(store, sleeps: [], recovery: 10) + try await store.syncWrite { db in + try db.execute(sql: """ + CREATE TRIGGER refuse_selected_day BEFORE INSERT ON dailyMetric + WHEN NEW.day = '2021-06-15' BEGIN SELECT RAISE(ABORT, 'test write failure'); END + """) + } + do { try await commit(store, sleeps: [sleep(1_623_711_600, 1_623_736_800)], recovery: 70); XCTFail("write must fail") } + catch { } + let rows = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + let scores = try await store.dailyMetrics(deviceId: source, from: day, to: day) + let provenance = try await store.scoreInputSource(deviceId: source, day: day, key: "recovery") + XCTAssertTrue(rows.isEmpty) + XCTAssertEqual(scores.first?.recovery, 10) + XCTAssertEqual(provenance, "active-strap") + } + + func testCancelledSelectedWriteBanksNothing() async throws { + let store = try await WhoopStore.inMemory() + var finish: AsyncStream.Continuation! + let gate = AsyncStream { finish = $0 } + let task = Task { + for await _ in gate { } + try await commit(store, sleeps: [sleep(1_623_711_600, 1_623_736_800)]) + } + task.cancel() + finish.finish() + do { try await task.value; XCTFail("cancellation must propagate") } + catch is CancellationError { } + let rows = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + let scores = try await store.dailyMetrics(deviceId: source, from: day, to: day) + XCTAssertTrue(rows.isEmpty) + XCTAssertTrue(scores.isEmpty) + } + func testDependentUpdatePreservesOtherFieldsAndCommitsProvenanceAtomically() async throws { + let store = try await WhoopStore.inMemory() + let next = "2021-06-16" + let neighbor = DailyMetric(day: next, totalSleepMin: 360, efficiency: 0.8, deepMin: 90, + remMin: 90, lightMin: 180, disturbances: 2, restingHr: 55, avgHrv: 40, + recovery: 10, strain: 8, exerciseCount: 1) + _ = try await store.upsertDailyMetrics([neighbor], deviceId: source) + try await store.persistComputedScores(dailyMetrics: [daily(70)], metricPoints: [], provenance: [], + deviceId: source, from: day, to: day, sleepSessions: [sleep(1_623_711_600, 1_623_736_800)], + dependentRecoveryUpdates: [.init(day: next, recovery: 60, skinTempDevC: 0.2, sourceId: "active-strap")]) + let after = try await store.dailyMetrics(deviceId: source, from: next, to: next) + XCTAssertEqual(after.first?.totalSleepMin, neighbor.totalSleepMin) + XCTAssertEqual(after.first?.strain, neighbor.strain) + XCTAssertEqual(after.first?.recovery, 60) + let provenance = try await store.scoreInputSource(deviceId: source, day: next, key: "recovery") + XCTAssertEqual(provenance, "active-strap") + try await store.syncWrite { db in + try db.execute(sql: "CREATE TRIGGER refuse_dependent BEFORE UPDATE ON dailyMetric WHEN NEW.day = '2021-06-16' BEGIN SELECT RAISE(ABORT, 'dependent failure'); END") + } + do { + try await store.persistComputedScores(dailyMetrics: [daily(90)], metricPoints: [], provenance: [], + deviceId: source, from: day, to: day, sleepSessions: [sleep(1_623_711_600, 1_623_740_400)], + dependentRecoveryUpdates: [.init(day: next, recovery: nil, skinTempDevC: nil)]) + XCTFail("dependent failure must roll back selected day and sleep") + } catch { } + let primary = try await store.dailyMetrics(deviceId: source, from: day, to: day) + let sleeps = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + XCTAssertEqual(primary.first?.recovery, 70) + XCTAssertEqual(sleeps.first?.endTs, 1_623_736_800) + } + + func testDetailsAndShiftedDuplicateCleanupCommitTogether() async throws { + let store = try await WhoopStore.inMemory() + let fresh = sleep(1_623_711_600, 1_623_736_800) + let old = sleep(fresh.startTs - 60, fresh.endTs - 3600) + let neighbor = sleep(fresh.startTs - 86400, fresh.endTs - 86400) + _ = try await store.upsertSleepSessions([old, neighbor], deviceId: source) + func write() async throws { + try await store.persistComputedScores(dailyMetrics: [daily(70)], metricPoints: [], provenance: [], + deviceId: source, from: day, to: day, sleepSessions: [fresh], + sleepMotion: [fresh.startTs: [0.1, 0.2]], sleepStates: [fresh.startTs: [1, 2]], + selectedSleepBounds: 1_623_715_200...1_623_801_599) + } + try await store.syncWrite { db in + try db.execute(sql: "CREATE TRIGGER refuse_details BEFORE UPDATE OF motionJSON ON sleepSession BEGIN SELECT RAISE(ABORT, 'detail failure'); END") + } + do { try await write(); XCTFail("detail failure must roll back selected sleep and day") } catch { } + let before = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + XCTAssertEqual(Set(before.map(\.startTs)), Set([old.startTs, neighbor.startTs])) + let dailyBefore = try await store.dailyMetrics(deviceId: source, from: day, to: day) + XCTAssertTrue(dailyBefore.isEmpty) + try await store.syncWrite { db in try db.execute(sql: "DROP TRIGGER refuse_details") } + try await write() + let after = try await store.sleepSessions(deviceId: source, from: 0, to: 2_000_000_000, limit: 100) + XCTAssertEqual(Set(after.map(\.startTs)), Set([fresh.startTs, neighbor.startTs])) + let motion = try await store.sessionMotion(deviceId: source, sessionStart: fresh.startTs) + let states = try await store.sessionSleepState(deviceId: source, sessionStart: fresh.startTs) + XCTAssertEqual(motion, [0.1, 0.2]); XCTAssertEqual(states, [1, 2]) + } + + func testSelectedWriteDoesNotBankNeighboringContextMarkersOrTags() async throws { + let store = try await WhoopStore.inMemory() + let prior = "2021-06-14" + let key = "day_cycle_onset_ts" + let neighbor = MetricPoint(day: prior, key: key, value: 123) + try await store.persistMetricSeriesWithProvenance(points: [neighbor], + provenance: [.init(day: prior, key: key, sourceId: "prior-source")], deviceId: source) + try await store.persistComputedScores(dailyMetrics: [daily(70)], + metricPoints: [.init(day: prior, key: key, value: 999), .init(day: day, key: key, value: 456)], + provenance: [.init(day: prior, key: key, sourceId: "wrong-source")], + deviceId: source, from: day, to: day, replaceMetricKeys: [key], + additionalMetricPoints: [.init(deviceId: source, point: .init(day: prior, key: key, value: 777))], sleepSessions: [], + selectedSleepBounds: 1_623_715_200...1_623_801_599) + let rows = try await store.metricSeries(deviceId: source, key: key, from: prior, to: prior) + let tag = try await store.scoreInputSource(deviceId: source, day: prior, key: key) + XCTAssertEqual(rows.first?.value, 123); XCTAssertEqual(tag, "prior-source") + } + +} diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index a47bc0fb3f..f648c62f83 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -5,6 +5,24 @@ import WhoopProtocol import WhoopStore import StrandAnalytics +/// Tracks failable store reads across the selected scan without treating a read error as missing data. +private final class SelectedNightReadState: @unchecked Sendable { + private let lock = NSLock() + private var failed = false + var hasFailure: Bool { + lock.lock(); defer { lock.unlock() } + return failed + } + private func recordFailure() { + lock.lock(); defer { lock.unlock() } + failed = true + } + func read(_ operation: () async throws -> Value) async -> Value? { + do { return try await operation() } + catch { recordFailure(); return nil } + } +} + /// On-device "intelligence": computes recovery / day-strain / sleep from the raw strap streams using /// the same model shape WHOOP uses (HRV vs personal baseline ~60%, resting HR ~20%, sleep ~15%, /// respiration ~5%; strain 0–21 from cardiovascular load). This is what makes NOOP independent of @@ -39,6 +57,27 @@ final class IntelligenceEngine: ObservableObject { @Published var computing = false @Published var note: String? + enum SelectedSleepResult { + case completed, noSleepDetected, insufficientData, invalidDate, busy, deletionSuppressed, failed + } + + // An explicitly selected completed wake day; the normal 21-day scan is independent. + func recalculateSleepNight(day: String) async throws -> SelectedSleepResult { + try Task.checkCancellation() + guard let window = SleepRecalculationWindow.make(day: day, timeZone: .current, + now: Int(Date().timeIntervalSince1970)) else { + return .invalidDate + } + guard !computing else { return .busy } + if repo.dismissedSleepWindows().contains(where: { $0.start < window.end && $0.end >= window.start }) { + return .deletionSuppressed + } + var outcome = SelectedSleepResult.failed + await analyzeRecent(maxDays: 1, selectedNight: window, onSelectedNightFinished: { outcome = $0 }) + try Task.checkCancellation() + return outcome + } + /// #899-A re-arm: a `force: true` recompute (a post-backfill rescore AppModel kicks off after a sync) /// that arrives while an idle-tick pass already holds the `computing` lock would otherwise be SILENTLY /// dropped, so a freshly-synced night intermittently never gets re-scored until the next cycle and Today @@ -760,13 +799,17 @@ final class IntelligenceEngine: ObservableObject { /// live night can be scored against your norm. func analyzeRecent(maxDays: Int = 21, force: Bool = true, skipIfUnchanged: Bool = false, triggerLabel: String? = nil, preserveUnscoredHistory: Bool = false, - onPersisted: (() -> Void)? = nil) async { + onPersisted: (() -> Void)? = nil, + selectedNight: SleepRecalculationWindow? = nil, + onSelectedNightFinished: ((SelectedSleepResult) -> Void)? = nil) async { + precondition(selectedNight == nil || maxDays == 1) + let selectedReadState = SelectedNightReadState() // #899-A: a concurrent pass already holds the lock. A NON-forced idle tick is safe to drop (the // in-flight pass already covers the same window). But a FORCED call is a real update path (a // post-backfill rescore after a sync) , dropping it would leave a freshly-synced night unscored // until the next cycle. Re-arm instead: flag it so the running pass's `defer` re-invokes once. guard !computing else { - if force { + if force && selectedNight == nil { // Said once per running pass, not per trigger: a pass that holds the lock for hours otherwise // turns every post-offload re-score into a silent no-op, and the log shows syncs but no scores. if !pendingForcedRescore, let started = runningPassStart { @@ -790,7 +833,7 @@ final class IntelligenceEngine: ObservableObject { pendingForcedRescore = false // A forced update queued during the upgrade repair owes a normal recent pass, // not another full-history repair (#2606). Its completion belongs to this pass. - let followUpDays = preserveUnscoredHistory ? 21 : maxDays + let followUpDays = (preserveUnscoredHistory || selectedNight != nil) ? 21 : maxDays Task { await self.analyzeRecent(maxDays: followUpDays, force: true) } @@ -817,7 +860,7 @@ final class IntelligenceEngine: ObservableObject { // Oura / Apple Watch / re-added-WHOOP install the per-device read returned 0, so this gate never // fired and a night finishing after launch stayed unscored until relaunch. Scoring below still reads // the registry's ACTIVE device (`owner`); only this change-detector needed to be cross-device. - let wmKey = (try? await store.analysisFingerprint()) ?? "" + let wmKey = selectedNight == nil ? ((await selectedReadState.read { try await store.analysisFingerprint() }) ?? "") : "" // #1538: read the stored watermark ONCE for both gates and the attribution line below. There is no // suspension point between them, so the three reads this replaces could not disagree — but the log // line asserting `newData` and the gate deciding whether to run must be the SAME comparison by @@ -893,18 +936,18 @@ final class IntelligenceEngine: ObservableObject { // state that skipped the capture and just cleared, which is exactly where #1681 lived. The // Kotlin post-offload gate makes the same point in its own words: "captured before the run, // written only on success". - let owedToken = RescoreBackgroundScheduler.markRescoreOwed(passStarting: true) + let owedToken = selectedNight == nil ? RescoreBackgroundScheduler.markRescoreOwed(passStarting: true) : nil let up = UserProfile(weightKg: profile.weightKg, heightCm: profile.heightCm, age: Double(profile.age), sex: profile.sex, stepTicksPerStep: profile.stepTicksPerStep) let maxHR = profile.hrMaxOverride > 0 ? Double(profile.hrMaxOverride) : nil - let now = Int(Date().timeIntervalSince1970) + let now = selectedNight.map { $0.end - 1 } ?? Int(Date().timeIntervalSince1970) // Device wall-clock offset (seconds east of UTC) for the sleep detector's daytime // false-sleep guard (#90): the stager places each window's center on the LOCAL clock // so only genuinely-daytime windows face the stricter nap bar. (Computed once; a DST // boundary inside the window is a negligible edge case for an hour-of-day band.) - let tzOffset = TimeZone.current.secondsFromGMT() + let tzOffset = selectedNight?.offsetSeconds ?? TimeZone.current.secondsFromGMT() // ── Pass 1: analyse each offloaded night against the IMPORTED-ONLY baseline. For a BLE-only // user the imported daily rows are empty, so the HRV baseline isn't usable yet and recovery is @@ -917,7 +960,9 @@ final class IntelligenceEngine: ObservableObject { // imported HRV/RHR/resp fields from computed values). Using the merge contaminated this very // "imported-only" baseline with computed values and made the fold window depend on whichever // refresh last ran (4000 vs 120 days). This mirrors the Android port's `days(importedDeviceId)`. - let hist = ((try? await store.dailyMetrics(deviceId: deviceId, from: "0000-01-01", to: "9999-12-31")) ?? []) + let histFrom = selectedNight.map { AnalyticsEngine.dayString($0.start - 20 * 86_400, offsetSec: tzOffset) } ?? "0000-01-01" + let histTo = selectedNight?.day ?? "9999-12-31" + let hist = ((await selectedReadState.read { try await store.dailyMetrics(deviceId: deviceId, from: histFrom, to: histTo) }) ?? []) .sorted { $0.day < $1.day } // HRV baseline honours the manual "Recalibrate baseline" epoch (noop.hrvBaselineEpoch); the // resting-HR baseline honours the Charge-wide sibling (noop.recoveryBaselineEpoch). Pass the @@ -947,6 +992,17 @@ final class IntelligenceEngine: ObservableObject { // seed resp/skin-temp baselines the same way avgHrv seeds the HRV baseline. var nightlyRespByDay: [String: Double?] = [:] var nightlySkinByDay: [String: Double?] = [:] + if let selectedNight { + // Reuse bounded persisted baseline inputs; a one-day scan must not erase calibration. + let from = AnalyticsEngine.dayString(selectedNight.start - 20 * 86_400, offsetSec: tzOffset) + let prior = (await selectedReadState.read { try await store.dailyMetrics(deviceId: deviceId + "-noop", from: from, to: selectedNight.day) }) ?? [] + for row in prior where row.day != selectedNight.day { + nightlyHrvByDay[row.day] = row.avgHrv + nightlyRhrByDay[row.day] = row.restingHr.map(Double.init) + nightlyRespByDay[row.day] = row.respRateBpm + nightlySkinByDay[row.day] = row.skinTempC + } + } // Device-registry snapshot for per-day owner resolution (invariant I2 , a day's scores come from // exactly ONE source). Read once before the loop: the paired-device list + the active id are @@ -974,7 +1030,7 @@ final class IntelligenceEngine: ObservableObject { // day keys are LOCAL calendar days, consistent with the dashboard's local "today" lookup. A // west-of-UTC user's evening crosses midnight UTC; bucketing by UTC put it in the next UTC day, // which the local read never found (Toronto/UTC-4 report). - let nowLocalMidnight = Self.midnightLocal(now, offsetSec: tzOffset) + let nowLocalMidnight = selectedNight?.start ?? Self.midnightLocal(now, offsetSec: tzOffset) // ── Learned habitual midsleep (#547) ────────────────────────────────── // Compute the user's habitual midsleep ONCE per run from the trailing sleep history so the @@ -987,7 +1043,7 @@ final class IntelligenceEngine: ObservableObject { // and the Sleep tab resolve to the identical block. (#547) let (habitualMidsleepSec, nightlyHours) = await Self.computeHabitualSleep( store: store, importedId: deviceId, computedId: deviceId + "-noop", - windowStart: nowLocalMidnight - maxDays * 86_400 - StreamReadCap.lookbackSeconds, + windowStart: nowLocalMidnight - (selectedNight == nil ? maxDays : 21) * 86_400 - StreamReadCap.lookbackSeconds, windowEnd: now, finishedBefore: nowLocalMidnight, offsetSec: tzOffset) // Wave 0 (SL1/T1): personal sleep REGULARITY + population-anchored NEED, computed ONCE from the // trailing per-night durations and threaded to every analyzeDay below (mirrors the midsleep @@ -1122,6 +1178,7 @@ final class IntelligenceEngine: ObservableObject { dayScanCacheConfigSig = dayCacheConfigSig dayCacheConfigDropped = true } + if let selectedNight { dayScanCache.removeValue(forKey: selectedNight.day) } let inDayScanCache = dayScanCache let (scanned, skippedDayLines, updatedDayScanCache): @@ -1181,12 +1238,12 @@ final class IntelligenceEngine: ObservableObject { // over the same window. A field capture showed `rr[... truncated=10]` — ten nights scored on R-R // missing its tail. See `StreamReadCap` for the arithmetic. let hrWindow = SlidingStreamWindow(tsOf: { $0.ts }, limit: StreamReadCap.hr) { o, f, t in - try? await store.hrSamples(deviceId: o, from: f, to: t, limit: StreamReadCap.hr) + await selectedReadState.read { try await store.hrSamples(deviceId: o, from: f, to: t, limit: StreamReadCap.hr) } } - let activeWhoop5RR = (try? await store.isWhoop5RRSource(deviceId: regActiveId)) ?? true + let activeWhoop5RR = (await selectedReadState.read { try await store.isWhoop5RRSource(deviceId: regActiveId) }) ?? true let rrWindow = SlidingStreamWindow(tsOf: { $0.ts }, limit: StreamReadCap.rr) { o, f, t in - try? await store.rrIntervals(deviceId: o, from: f, to: t, limit: StreamReadCap.rr, - unlabelledAliasOfWhoop5: activeWhoop5RR && o == Repository.whoopSource) + await selectedReadState.read { try await store.rrIntervals(deviceId: o, from: f, to: t, limit: StreamReadCap.rr, + unlabelledAliasOfWhoop5: activeWhoop5RR && o == Repository.whoopSource) } } var paceMark = DispatchTime.now().uptimeNanoseconds for offset in 0..> sdnn = @@ -1803,8 +1866,8 @@ final class IntelligenceEngine: ObservableObject { spo2CandidateMean = cand.mean } } else { - let auxSamples = (try? await store.v18AuxSamples( - deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] + let auxSamples = (await selectedReadState.read { try await store.v18AuxSamples( + deviceId: owner, from: from, to: to, limit: 200_000) }) ?? [] if !auxSamples.isEmpty { if let cand = AnalyticsEngine.nightlySpo2CandidateMean(res.sleepSessions, aux: auxSamples) { spo2CandidateMean = cand.mean @@ -1884,6 +1947,15 @@ final class IntelligenceEngine: ObservableObject { }.value // #1005: write the loop's updated reuse cache back to the (main-actor) stored property. The pass ran // to completion above (`.value` awaited), so there is no concurrent access. + if selectedNight != nil && selectedReadState.hasFailure { + onSelectedNightFinished?(.failed) + return + } + guard selectedNight == nil || !scanned.isEmpty else { + onSelectedNightFinished?(.insufficientData) + return + } + guard selectedNight == nil || !Task.isCancelled else { return } dayScanCache = updatedDayScanCache // #1538: the pass after the day loop was never measured. The cost line above brackets the loop and // is emitted the moment it returns, so a pass whose time went somewhere later reported a small @@ -2077,11 +2149,11 @@ final class IntelligenceEngine: ObservableObject { // re-labelled rows (both written under `deviceId`), and apple-health carries Health imports , // a detected bout overlapping ANY of them is skipped below. Port of the Android dedup block. // (`computedId` is bound once above, before the off-actor scan loop.) - let windowStart = now - maxDays * 86_400 - StreamReadCap.lookbackSeconds - var realWorkouts = (try? await store.workouts(deviceId: deviceId, from: windowStart, - to: now, limit: 100_000)) ?? [] - realWorkouts += (try? await store.workouts(deviceId: "apple-health", from: windowStart, - to: now, limit: 100_000)) ?? [] + let windowStart = selectedNight?.readStart ?? (now - maxDays * 86_400 - StreamReadCap.lookbackSeconds) + var realWorkouts = (await selectedReadState.read { try await store.workouts(deviceId: deviceId, from: windowStart, + to: now, limit: 100_000) }) ?? [] + realWorkouts += (await selectedReadState.read { try await store.workouts(deviceId: "apple-health", from: windowStart, + to: now, limit: 100_000) }) ?? [] markPostLoopPhase("baselines") // ── Pass 2: re-score ONLY recovery against the now-seeded baseline (cheap, baseline-dependent); @@ -2110,7 +2182,13 @@ final class IntelligenceEngine: ObservableObject { // refreshed rows so the daily aggregate below scores the corrected breakdown. A no-op for nights // already staged from raw (idempotent) and for imported nights (raw never dense). This MUST run // before the scoring loop so the healed stages flow into Rest/recovery this same pass. - let editedRows = await repo.selfHealEditedStages(from: windowStart, to: now) + let editedRows: [CachedSleepSession] + if selectedNight != nil { + editedRows = ((await selectedReadState.read { try await store.sleepSessions(deviceId: computedId, from: windowStart, + to: now, limit: 100_000) }) ?? []).filter { $0.userEdited } + } else { + editedRows = await repo.selfHealEditedStages(from: windowStart, to: now) + } var cycleCandidates: [(owner: String, priority: Int)] = regDevices .map { device in let priority: Int @@ -2171,8 +2249,8 @@ final class IntelligenceEngine: ObservableObject { // The WHOLE apple-health daily history, chronological. Used both as a key-presence set for the // By-Day badge AND as the SDNN+RHR input for the Apple-Watch recovery fold below (a watch-only user // has these daily aggregates but no raw stream, so the raw-HR scoring loop never touched them). - let appleRows = ((try? await store.dailyMetrics(deviceId: Repository.appleHealthSource, - from: "0000-01-01", to: "9999-12-31")) ?? []) + let appleRows = ((await selectedReadState.read { try await store.dailyMetrics(deviceId: Repository.appleHealthSource, + from: selectedNight?.day ?? "0000-01-01", to: selectedNight?.day ?? "9999-12-31") }) ?? []) .sorted { $0.day < $1.day } let appleHealthDays = Set(appleRows.map { $0.day }) @@ -2189,12 +2267,12 @@ final class IntelligenceEngine: ObservableObject { // each analytics-only/backfill decision can emit one `.workouts` line per derived bout. Diagnostic // only; this path no longer publishes or reconciles generic workout rows. let workoutsTraceActive = TestCentre.active(.workouts) - let strictCanonicalAlias = (try? await store.isWhoop5RRSource(deviceId: regActiveId)) ?? true + let strictCanonicalAlias = (await selectedReadState.read { try await store.isWhoop5RRSource(deviceId: regActiveId) }) ?? true let legacySnapshots = await Self.legacyScoreSnapshots( store: store, computedId: computedId, from: oldestDay, to: newestDay, fresh: scoredNights.map { $0.daily }, ownerByDay: resolvedScoreOwnerByDay, nowLocalMidnight: nowLocalMidnight, now: now, offsetSec: tzOffset, - maxDays: maxDays, strictCanonicalAlias: strictCanonicalAlias) + maxDays: maxDays, strictCanonicalAlias: strictCanonicalAlias, readState: selectedNight == nil ? nil : selectedReadState) var appliedLegacySnapshots: [String: LegacyScoreSnapshot] = [:] var paceMark = DispatchTime.now().uptimeNanoseconds for night in scoredNights { @@ -2204,7 +2282,8 @@ final class IntelligenceEngine: ObservableObject { // is stable under a bedtime edit (only the onset/`startTsAdjusted` moves), so end-day is the // right key. Filtering here keeps a single-night edit overriding only its OWN night instead of // every night. `effectiveStartTs` (the #318 user-corrected onset) is preserved on the row. - let dayEditedRows = Self.editedRowsForDay(editedRows, day: night.daily.day, tzOffsetSeconds: tzOffset) + let dayEditedRows = selectedNight.map { window in editedRows.filter { window.contains($0.endTs) && AnalyticsEngine.sleepSession(fromProvided: $0) == nil } } + ?? Self.editedRowsForDay(editedRows, day: night.daily.day, tzOffsetSeconds: tzOffset) let editsByStart = Dictionary(dayEditedRows.map { ($0.startTs, $0) }, uniquingKeysWith: { a, _ in a }) var daily = sleepEditedDaily(night.daily, detected: night.cachedSleep, editsByStart: editsByStart, habitualMidsleepSec: habitualMidsleepSec) @@ -2420,8 +2499,8 @@ final class IntelligenceEngine: ObservableObject { sleepMin: row.totalSleepMin, hrv: row.avgHrv, rhr: row.restingHr, source: .appleHealth, confidence: w.confidence)) } - if !appleRecoveryRows.isEmpty { - _ = try? await store.upsertDailyMetrics(appleRecoveryRows, deviceId: Repository.appleHealthSource) + if selectedNight == nil && !appleRecoveryRows.isEmpty { + _ = await selectedReadState.read { try await store.upsertDailyMetrics(appleRecoveryRows, deviceId: Repository.appleHealthSource) } } markPostLoopPhase("watchFold") @@ -2454,7 +2533,7 @@ final class IntelligenceEngine: ObservableObject { // computed reconcile below, so the fold's rows survive the stale-row eviction. var importScoredDays = Set(dailies.map { $0.day }).union(importedWhoopDays).union(appleHealthDays) for source in Repository.wearableImportSources { - let rows = ((try? await store.dailyMetrics(deviceId: source, from: oldestDay, to: newestDay)) ?? []) + let rows = ((await selectedReadState.read { try await store.dailyMetrics(deviceId: source, from: oldestDay, to: newestDay) }) ?? []) .sorted { $0.day < $1.day } guard !rows.isEmpty else { continue } let byDay = Dictionary(rows.map { ($0.day, $0) }, uniquingKeysWith: { a, _ in a }) @@ -2474,6 +2553,15 @@ final class IntelligenceEngine: ObservableObject { } } + if selectedNight != nil && selectedReadState.hasFailure { + onSelectedNightFinished?(.failed) + return + } + if selectedNight != nil && out.allSatisfy({ ($0.sleepMin ?? 0) <= 0 }) { + onSelectedNightFinished?(.noSleepDetected) + return // No new sleep evidence: retain previously saved rows and totals together. + } + // Apply the exact snapshot only after current-score traces and derived series were produced from the // current inputs. A legacy snapshot must not masquerade as a value recalculated against today's // baselines; it only protects persisted R-R-derived cells from a destructive nil overwrite. @@ -2542,7 +2630,46 @@ final class IntelligenceEngine: ObservableObject { } markerSources = sourceIds } + // Drop any freshly-detected session that overlaps a night the user has already hand-corrected. + // A detected onset can drift second-to-second as more raw data arrives, so without this the + // re-detected night would upsert as a SECOND row beside the edited one (different startTs ⇒ no + // ON CONFLICT match), and mergeDay would DOUBLE-COUNT both into an inflated time-in-bed. The + // edited row is already stored (preserved by the upsert guard), so we simply don't re-insert its + // detected twin. Sleep has no delete-reinsert pass (unlike dailyMetric/workout), so this is the + // idempotency guard for the edited case. (#318) + let editedWindows = editedRows.map { (start: $0.effectiveStartTs, end: $0.endTs) } + // #68: also drop any re-detected night the user has DELETED , a dismissedSleep tombstone keeps it + // from regenerating, mirroring the dismissed-WORKOUT guard above. Overlap (not exact startTs) + // because a re-detected onset drifts as more raw data arrives. (Android twin: dismissedWindows.) + let dismissedWindows = repo.dismissedSleepWindows() + let skipWindows = editedWindows + dismissedWindows + let cachedSleepKept = cachedSleep.filter { s in + !skipWindows.contains { s.startTs < $0.end && $0.start < s.endTs } // time-overlap test + } + if selectedNight != nil && selectedReadState.hasFailure { + onSelectedNightFinished?(.failed) + return + } + if let selectedNight, dismissedWindows.contains(where: { $0.start < selectedNight.end && $0.end >= selectedNight.start }) { + onSelectedNightFinished?(.deletionSuppressed) + return + } + var selectedMotion: [Int: [Double]] = [:] + var selectedStates: [Int: [Int]] = [:] + if selectedNight != nil { + let starts = Set(cachedSleepKept.map(\.startTs)) + for night in scoredNights { + for (start, values) in night.sessionMotion where starts.contains(start) { selectedMotion[start] = values } + for (start, values) in night.sessionSleepState where starts.contains(start) { selectedStates[start] = values } + } + } do { + let recoveryUpdates: [SleepRecoveryUpdate] + if let selectedNight { + recoveryUpdates = try await selectedNightRecoveryUpdates(store: store, window: selectedNight, + fresh: persistedDailies, ownerByDay: resolvedScoreOwnerByDay) + .filter { legacySnapshots[$0.day] == nil } + } else { recoveryUpdates = [] } // Repair only the days actually recomputed. A full-history repair must not erase // older cached scores/provenance whose raw inputs are no longer retained. let dailiesByDay = Dictionary(grouping: persistedDailies, by: \.day) @@ -2564,7 +2691,10 @@ final class IntelligenceEngine: ObservableObject { replaceMetricKeys: markerKeys, additionalMetricPoints: preserveUnscoredHistory ? markerPointsByDay[from, default: []] : markerPoints, - replaceMetricSourceIds: markerSources) + replaceMetricSourceIds: markerSources, + sleepSessions: selectedNight == nil ? nil : cachedSleepKept, + dependentRecoveryUpdates: recoveryUpdates, sleepMotion: selectedMotion, sleepStates: selectedStates, + selectedSleepBounds: selectedNight.map { $0.start...($0.end - 1) }) } } catch { diagnosticSink?("re-score: daily persistence failed; history repair remains pending", nil) @@ -2584,9 +2714,9 @@ final class IntelligenceEngine: ObservableObject { // WhoopDao.replaceComputedScoreWindow empty guard. if !preserveUnscoredHistory && !persistedDailies.isEmpty { let freshKeys = Set(persistedDailies.map { $0.day }) - let existingWindow = (try? await store.dailyMetrics(deviceId: computedId, from: oldestDay, to: newestDay)) ?? [] + let existingWindow = (await selectedReadState.read { try await store.dailyMetrics(deviceId: computedId, from: oldestDay, to: newestDay) }) ?? [] for stale in existingWindow where !freshKeys.contains(stale.day) { - _ = try? await store.deleteDailyMetrics(deviceId: computedId, from: stale.day, to: stale.day) + _ = await selectedReadState.read { try await store.deleteDailyMetrics(deviceId: computedId, from: stale.day, to: stale.day) } } } markPostLoopPhase("persist") @@ -2595,6 +2725,7 @@ 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. + if selectedNight == nil { 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 @@ -2616,11 +2747,11 @@ final class IntelligenceEngine: ObservableObject { heightCm: profile.heightCm, weightKg: profile.weightKg, computedId: computedId, satKey: IntelligenceEngine.saturdayKey(onOrBefore: newestDay)) if !faPts.isEmpty { - try? await store.persistMetricSeriesWithProvenance( + await selectedReadState.read { try await store.persistMetricSeriesWithProvenance( points: faPts, provenance: Self.vo2MaxProvenance(points: faPts, waistCm: profile.waistCm), deviceId: computedId - ) + ) } } // ── Vitality / Body Age (Phase 7) , weekly, keyed to the week's Saturday ──────────────────── @@ -2641,10 +2772,10 @@ final class IntelligenceEngine: ObservableObject { steps: vSteps.isEmpty ? nil : vSteps.reduce(0, +) / Double(vSteps.count)) if let vRes = VitalityEngine.compute(vInputs) { let satKey = IntelligenceEngine.saturdayKey(onOrBefore: newestDay) - _ = try? await store.upsertMetricSeries([ + _ = await selectedReadState.read { try await store.upsertMetricSeries([ MetricPoint(day: satKey, key: "vitality", value: vRes.vitality), MetricPoint(day: satKey, key: "body_age", value: vRes.bodyAge), - ], deviceId: computedId) + ], deviceId: computedId) } } markPostLoopPhase("weekly") @@ -2706,8 +2837,8 @@ final class IntelligenceEngine: ObservableObject { // `appleDaily.steps` (Int?), never into a dailyMetric `steps` row , so the old `dailyMetrics` read // was always empty and the calibration never advanced past "Need 3 more days" (Android already reads // appleDaily here, IntelligenceEngine.kt:676). `store.appleDaily(deviceId:from:to:)` already exists. - let appleRows = (try? await store.appleDaily(deviceId: Repository.appleHealthSource, - from: calOldest, to: newestDay)) ?? [] + let appleRows = (await selectedReadState.read { try await store.appleDaily(deviceId: Repository.appleHealthSource, + from: calOldest, to: newestDay) }) ?? [] var refSteps: [String: Double] = [:] for r in appleRows { if let s = r.steps, s > 0 { refSteps[r.day] = Double(s) } } // Per-day motion volume over the calibration window, read from the owner-resolved strap streams. @@ -2724,7 +2855,7 @@ final class IntelligenceEngine: ObservableObject { var motionWindow: Set = [] for off in 0.. 0 ? .completed : .noSleepDetected) + } + onPersisted?() + } + } + + private func selectedNightRecoveryUpdates(store: WhoopStore, window: SleepRecalculationWindow, + fresh: [DailyMetric], ownerByDay: [String: String]) async throws -> [SleepRecoveryUpdate] { + let wallNow = Date() + let today = AnalyticsEngine.dayString(Int(wallNow.timeIntervalSince1970), offsetSec: TimeZone.current.secondsFromGMT(for: wallNow)) + let from = AnalyticsEngine.dayString(AnalyticsEngine.dayStartUtcSeconds(today) - 20 * 86_400) + guard window.day >= from else { return [] } + let to = today + let imported = try await store.dailyMetrics(deviceId: deviceId, from: from, to: to) + var own = try await store.dailyMetrics(deviceId: deviceId + "-noop", from: from, to: to) + let old = own.first { $0.day == window.day } + let updated = fresh.first { $0.day == window.day } + var baselineChanged = old == nil || old?.avgHrv != updated?.avgHrv || old?.restingHr != updated?.restingHr + || old?.respRateBpm != updated?.respRateBpm || old?.skinTempC != updated?.skinTempC + let oldSource = try await store.scoreInputSource(deviceId: deviceId + "-noop", day: window.day, key: "strain") + if let newSource = ownerByDay[window.day], newSource != oldSource { baselineChanged = true } + own.removeAll { $0.day == window.day } + own += fresh + var sources = ownerByDay + for row in own where sources[row.day] == nil { + sources[row.day] = try await store.scoreInputSource(deviceId: deviceId + "-noop", day: row.day, key: "strain") + } + let updates = SleepRecalculationDependents.updates(selectedDay: window.day, anchorDay: today, refreshBaseline: baselineChanged, imported: imported, computed: own, + sourceByDay: sources, importedSource: deviceId, hrvEpoch: Baselines.hrvBaselineEpoch(), + recoveryEpoch: Baselines.recoveryBaselineEpoch()) + var tagged: [SleepRecoveryUpdate] = [] + for update in updates { + // Neighboring Charge uses its saved R-R input, not this scan's current strap. + let source = update.day == window.day ? update.sourceId : try await store.scoreInputSource( + deviceId: deviceId + "-noop", day: update.day, key: "recovery") + tagged.append(SleepRecoveryUpdate(day: update.day, recovery: update.recovery, + skinTempDevC: update.skinTempDevC, sourceId: source)) + } + return tagged } /// UserDefaults key for the #836 idle-tick gate: the complete raw-analysis fingerprint the last completed @@ -3233,9 +3396,14 @@ final class IntelligenceEngine: ObservableObject { store: WhoopStore, computedId: String, from: String, to: String, fresh: [DailyMetric], ownerByDay: [String: String], nowLocalMidnight: Int, now: Int, offsetSec: Int, maxDays: Int, - strictCanonicalAlias: Bool + strictCanonicalAlias: Bool, readState: SelectedNightReadState? = nil ) async -> [String: LegacyScoreSnapshot] { - let existing = (try? await store.dailyMetrics(deviceId: computedId, from: from, to: to)) ?? [] + let existing: [DailyMetric] + if let readState { + existing = (await readState.read { try await store.dailyMetrics(deviceId: computedId, from: from, to: to) }) ?? [] + } else { + existing = (try? await store.dailyMetrics(deviceId: computedId, from: from, to: to)) ?? [] + } let existingByDay = Dictionary(existing.map { ($0.day, $0) }, uniquingKeysWith: { a, _ in a }) var snapshots: [String: LegacyScoreSnapshot] = [:] for day in fresh { @@ -3253,14 +3421,26 @@ final class IntelligenceEngine: ObservableObject { let readFrom = dayStart - StreamReadCap.lookbackSeconds let readTo = sleepReadWindowEnd(dayStart: dayStart, nowLocalMidnight: nowLocalMidnight, now: now) let alias = strictCanonicalAlias && owner == Repository.whoopSource - guard (try? await store.legacyWhoop5RRWithheld( - deviceId: owner, from: readFrom, to: readTo, - unlabelledAliasOfWhoop5: alias)) == true else { continue } + let withheld: Bool? + if let readState { + withheld = await readState.read { try await store.legacyWhoop5RRWithheld( + deviceId: owner, from: readFrom, to: readTo, unlabelledAliasOfWhoop5: alias) } + } else { + withheld = try? await store.legacyWhoop5RRWithheld( + deviceId: owner, from: readFrom, to: readTo, unlabelledAliasOfWhoop5: alias) + } + guard withheld == true else { continue } + let recoverySource: String? + if let readState { + recoverySource = (await readState.read { try await store.scoreInputSource( + deviceId: computedId, day: day.day, key: "recovery") }) ?? nil + } else { + recoverySource = try? await store.scoreInputSource(deviceId: computedId, day: day.day, key: "recovery") + } snapshots[day.day] = LegacyScoreSnapshot( avgHrv: oldHrv, recovery: old.recovery, respRateBpm: old.respRateBpm, avgSdnn: old.avgSdnn, - recoverySource: try? await store.scoreInputSource( - deviceId: computedId, day: day.day, key: "recovery")) + recoverySource: recoverySource) } return snapshots } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..8a79f711b5 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage": "en", "strings": { + "Recalculate sleep": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Recalculate sleep"}}, "de": {"stringUnit": {"state": "translated", "value": "Schlaf neu berechnen"}}, "es": {"stringUnit": {"state": "translated", "value": "Recalcular el sueño"}}, "fr": {"stringUnit": {"state": "translated", "value": "Recalculer le sommeil"}}, "it": {"stringUnit": {"state": "translated", "value": "Ricalcola il sonno"}}, "pl": {"stringUnit": {"state": "translated", "value": "Przelicz sen"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Recalcular o sono"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пересчитать сон"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "重新计算睡眠"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "重新計算睡眠"}}}}, + "Select the date you woke up. Uses only data already on this device. Imports, manual edits and deleted windows are preserved.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Select the date you woke up. Uses only data already on this device. Imports, manual edits and deleted windows are preserved."}}, "de": {"stringUnit": {"state": "translated", "value": "Wähle das Datum, an dem du aufgewacht bist. Nutzt nur bereits auf diesem Gerät vorhandene Daten. Importe, manuelle Änderungen und gelöschte Zeiträume bleiben erhalten."}}, "es": {"stringUnit": {"state": "translated", "value": "Selecciona la fecha en que despertaste. Solo usa datos que ya están en este dispositivo. Se conservan las importaciones, las ediciones manuales y los intervalos eliminados."}}, "fr": {"stringUnit": {"state": "translated", "value": "Sélectionnez la date de votre réveil. Utilise uniquement les données déjà présentes sur cet appareil. Les importations, modifications manuelles et périodes supprimées sont conservées."}}, "it": {"stringUnit": {"state": "translated", "value": "Seleziona la data in cui ti sei svegliato. Usa solo dati già presenti su questo dispositivo. Importazioni, modifiche manuali e intervalli eliminati vengono mantenuti."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz datę przebudzenia. Wykorzystuje tylko dane już zapisane na tym urządzeniu. Importy, ręczne zmiany i usunięte przedziały pozostają zachowane."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Selecione a data em que acordou. Usa apenas dados já existentes neste dispositivo. As importações, edições manuais e períodos eliminados são preservados."}}, "ru": {"stringUnit": {"state": "translated", "value": "Выберите дату пробуждения. Используются только данные, уже сохранённые на этом устройстве. Импорт, ручные правки и удалённые интервалы сохраняются."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择你醒来的日期。仅使用已保存在此设备上的数据。保留导入记录、手动编辑和已删除的时段。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇你醒來的日期。僅使用已儲存在此裝置上的資料。保留匯入記錄、手動編輯和已刪除的時段。"}}}}, + "Wake date": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Wake date"}}, "de": {"stringUnit": {"state": "translated", "value": "Aufwachdatum"}}, "es": {"stringUnit": {"state": "translated", "value": "Fecha al despertar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Date du réveil"}}, "it": {"stringUnit": {"state": "translated", "value": "Data del risveglio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Data przebudzenia"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Data de despertar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Дата пробуждения"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "醒来日期"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "醒來日期"}}}}, + "Selected day's sleep recalculated from available data.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Selected day's sleep recalculated from available data."}}, "de": {"stringUnit": {"state": "translated", "value": "Der Schlaf des gewählten Tages wurde aus den verfügbaren Daten neu berechnet."}}, "es": {"stringUnit": {"state": "translated", "value": "Se recalculó el sueño del día seleccionado con los datos disponibles."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le sommeil du jour sélectionné a été recalculé à partir des données disponibles."}}, "it": {"stringUnit": {"state": "translated", "value": "Il sonno del giorno selezionato è stato ricalcolato dai dati disponibili."}}, "pl": {"stringUnit": {"state": "translated", "value": "Sen z wybranego dnia przeliczono na podstawie dostępnych danych."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O sono do dia selecionado foi recalculado a partir dos dados disponíveis."}}, "ru": {"stringUnit": {"state": "translated", "value": "Сон за выбранный день пересчитан по доступным данным."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已根据可用数据重新计算所选日期的睡眠。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已根據可用資料重新計算所選日期的睡眠。"}}}}, + "No sleep detected from available data. Saved edits and deleted windows were preserved.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "No sleep detected from available data. Saved edits and deleted windows were preserved."}}, "de": {"stringUnit": {"state": "translated", "value": "In den verfügbaren Daten wurde kein Schlaf erkannt. Gespeicherte Änderungen und gelöschte Zeiträume blieben erhalten."}}, "es": {"stringUnit": {"state": "translated", "value": "No se detectó sueño en los datos disponibles. Se conservaron las ediciones guardadas y los intervalos eliminados."}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucun sommeil détecté dans les données disponibles. Les modifications enregistrées et périodes supprimées ont été conservées."}}, "it": {"stringUnit": {"state": "translated", "value": "Nessun sonno rilevato nei dati disponibili. Le modifiche salvate e gli intervalli eliminati sono stati mantenuti."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie wykryto snu w dostępnych danych. Zachowano zapisane zmiany i usunięte przedziały."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi detetado sono nos dados disponíveis. As edições guardadas e os períodos eliminados foram preservados."}}, "ru": {"stringUnit": {"state": "translated", "value": "В доступных данных сон не обнаружен. Сохранённые правки и удалённые интервалы сохранены."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未在可用数据中检测到睡眠。已保留保存的编辑和已删除的时段。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未在可用資料中偵測到睡眠。已保留儲存的編輯和已刪除的時段。"}}}}, + "Not enough usable data for this day. Sync the strap, then try again. Recalculation does not fetch data.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Not enough usable data for this day. Sync the strap, then try again. Recalculation does not fetch data."}}, "de": {"stringUnit": {"state": "translated", "value": "Für diesen Tag fehlen nutzbare Daten. Synchronisiere das Band und versuche es erneut. Die Neuberechnung ruft keine Daten ab."}}, "es": {"stringUnit": {"state": "translated", "value": "No hay suficientes datos utilizables para este día. Sincroniza la pulsera y vuelve a intentarlo. El recálculo no descarga datos."}}, "fr": {"stringUnit": {"state": "translated", "value": "Les données exploitables sont insuffisantes pour ce jour. Synchronisez le bracelet, puis réessayez. Le recalcul ne récupère aucune donnée."}}, "it": {"stringUnit": {"state": "translated", "value": "Dati utilizzabili insufficienti per questo giorno. Sincronizza il cinturino e riprova. Il ricalcolo non recupera nuovi dati."}}, "pl": {"stringUnit": {"state": "translated", "value": "Brakuje wystarczających danych dla tego dnia. Zsynchronizuj opaskę i spróbuj ponownie. Przeliczanie nie pobiera danych."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não há dados utilizáveis suficientes para este dia. Sincronize a pulseira e tente novamente. O recálculo não obtém novos dados."}}, "ru": {"stringUnit": {"state": "translated", "value": "За этот день недостаточно пригодных данных. Синхронизируйте браслет и повторите попытку. Пересчёт не загружает данные."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "此日期没有足够的可用数据。请同步腕带后重试。重新计算不会获取新数据。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "此日期沒有足夠的可用資料。請同步腕帶後重試。重新計算不會取得新資料。"}}}}, + "Choose a completed past wake date.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Choose a completed past wake date."}}, "de": {"stringUnit": {"state": "translated", "value": "Wähle ein Aufwachdatum eines abgeschlossenen vergangenen Tages."}}, "es": {"stringUnit": {"state": "translated", "value": "Elige una fecha de despertar de un día pasado ya finalizado."}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisissez une date de réveil d’un jour passé terminé."}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli una data di risveglio di un giorno passato già concluso."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz datę przebudzenia z zakończonego dnia w przeszłości."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolha uma data de despertar de um dia passado já terminado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Выберите дату пробуждения за завершённый день в прошлом."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "请选择已结束的过去日期作为醒来日期。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "請選擇已結束的過去日期作為醒來日期。"}}}}, + "Another recalculation is running. Try again when it finishes.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Another recalculation is running. Try again when it finishes."}}, "de": {"stringUnit": {"state": "translated", "value": "Eine andere Neuberechnung läuft. Versuche es danach erneut."}}, "es": {"stringUnit": {"state": "translated", "value": "Hay otro recálculo en curso. Vuelve a intentarlo cuando termine."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un autre recalcul est en cours. Réessayez lorsqu’il sera terminé."}}, "it": {"stringUnit": {"state": "translated", "value": "È in corso un altro ricalcolo. Riprova quando sarà terminato."}}, "pl": {"stringUnit": {"state": "translated", "value": "Trwa inne przeliczanie. Spróbuj ponownie po jego zakończeniu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Está em curso outro recálculo. Tente novamente quando terminar."}}, "ru": {"stringUnit": {"state": "translated", "value": "Уже выполняется другой пересчёт. Повторите попытку после его завершения."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "另一次重新计算正在进行。请在完成后重试。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "另一次重新計算正在進行。請在完成後重試。"}}}}, + "Couldn't recalculate this day. Try again.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Couldn't recalculate this day. Try again."}}, "de": {"stringUnit": {"state": "translated", "value": "Dieser Tag konnte nicht neu berechnet werden. Versuche es erneut."}}, "es": {"stringUnit": {"state": "translated", "value": "No se pudo recalcular este día. Vuelve a intentarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Impossible de recalculer ce jour. Réessayez."}}, "it": {"stringUnit": {"state": "translated", "value": "Impossibile ricalcolare questo giorno. Riprova."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się przeliczyć tego dnia. Spróbuj ponownie."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível recalcular este dia. Tente novamente."}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось пересчитать этот день. Повторите попытку."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法重新计算此日期。请重试。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法重新計算此日期。請重試。"}}}}, + "This day includes deleted sleep. Its deletion was preserved; no recalculation was run.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "This day includes deleted sleep. Its deletion was preserved; no recalculation was run."}}, "de": {"stringUnit": {"state": "translated", "value": "Dieser Tag enthält gelöschten Schlaf. Die Löschung blieb erhalten; es wurde keine Neuberechnung ausgeführt."}}, "es": {"stringUnit": {"state": "translated", "value": "Este día incluye sueño eliminado. Se conservó la eliminación; no se realizó ningún recálculo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce jour comprend du sommeil supprimé. La suppression a été conservée ; aucun recalcul n’a été effectué."}}, "it": {"stringUnit": {"state": "translated", "value": "Questo giorno include sonno eliminato. L’eliminazione è stata mantenuta; non è stato eseguito alcun ricalcolo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten dzień zawiera usunięty sen. Usunięcie zachowano; nie wykonano przeliczania."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Este dia inclui sono eliminado. A eliminação foi preservada; não foi efetuado nenhum recálculo."}}, "ru": {"stringUnit": {"state": "translated", "value": "За этот день есть удалённый сон. Удаление сохранено; пересчёт не выполнялся."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "此日期包含已删除的睡眠。删除状态已保留,未进行重新计算。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "此日期包含已刪除的睡眠。刪除狀態已保留,未進行重新計算。"}}}}, "Move, relative to tonight": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Move, relative to tonight"}}, "de": {"stringUnit": {"state": "translated", "value": "Bewegung, relativ zu dieser Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "Movimiento, en relación con esta noche"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mouvement, par rapport à cette nuit"}}, "it": {"stringUnit": {"state": "translated", "value": "Movimento, rispetto a questa notte"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ruch, w odniesieniu do tej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Movimento, em relação a esta noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "Движение, относительно этой ночи"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "活动,相对于今晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "活動,相對於今晚"}}}}, "Route and GPS distance, without the heart-rate and calorie summary": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Route and GPS distance, without the heart-rate and calorie summary"}}, "de": {"stringUnit": {"state": "translated", "value": "Route und GPS-Distanz, ohne die Herzfrequenz- und Kalorienübersicht"}}, "es": {"stringUnit": {"state": "translated", "value": "Ruta y distancia GPS, sin el resumen de frecuencia cardiaca y calorías"}}, "fr": {"stringUnit": {"state": "translated", "value": "Itinéraire et distance GPS, sans le récapitulatif de fréquence cardiaque et de calories"}}, "it": {"stringUnit": {"state": "translated", "value": "Percorso e distanza GPS, senza il riepilogo di frequenza cardiaca e calorie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trasa i dystans GPS, bez podsumowania tętna i kalorii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Rota e distância GPS, sem o resumo de frequência cardíaca e calorias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маршрут и GPS-дистанция, без сводки пульса и калорий"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "路线和 GPS 距离,不含心率和卡路里摘要"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "路線和 GPS 距離,不含心率和卡路里摘要"}}}}, "Export Workout Route": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Export Workout Route"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsroute exportieren"}}, "es": {"stringUnit": {"state": "translated", "value": "Exportar ruta del entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Exporter l’itinéraire de la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Esporta il percorso dell’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Eksportuj trasę treningu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Exportar rota do treino"}}, "ru": {"stringUnit": {"state": "translated", "value": "Экспортировать маршрут тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导出运动路线"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯出運動路線"}}}}, diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 4870465dde..da3d540289 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -101,6 +101,12 @@ struct SleepView: View { /// which marks the session `userEdited` so a later strap sync can't revert the correction. (#318) @State private var wakeEdit: WakeEdit? + @State private var recalculationDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + @State private var recalculatingNight = false + @State private var recalculationNotice: String? + @State private var recalculationTask: Task? + @State private var requestedSleepDay: String? + /// Non-nil while the "Add nap" picker sheet is open (#508). Carries a seed bed/wake for the picker; /// saving routes through `repo.addManualNap`, which stages the chosen window from raw and writes it as /// its OWN separate session row (`userEdited = 1`) — never folded into the night's main sleep. @@ -165,6 +171,7 @@ struct SleepView: View { lazy: true, topBackground: resolved == nil ? nil : AnyView(sleepNightTopBackground)) { Group { + selectedNightRecalculationCard if let resolved { // Each top-level section fades + rises in sequence on first appear (Reduce-Motion safe). VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { @@ -223,7 +230,7 @@ struct SleepView: View { } } .onChangeCompat(of: intelligence.computing) { _ in observeResultChange() } - .onDisappear { resetResultNotice() } + .onDisappear { resetResultNotice(); recalculationTask?.cancel() } .task(id: resultNoticeRevision) { guard resultNoticeVisible else { return } do { try await Task.sleep(nanoseconds: 8_000_000_000) } @@ -254,6 +261,16 @@ struct SleepView: View { navDaysCache = SleepModel.navDays(navSessions: navSessions) model = buildModel() loadedSleepRefresh = refresh + if let selected = requestedSleepDay { + if let index = navDays.firstIndex(where: { group in + group.contains { Repository.localDayKey(Date(timeIntervalSince1970: TimeInterval($0.endTs))) == selected } + }) { + nightOffset = index + navNight = index == 0 ? nil : decodedNight(at: index) + model = buildModel() + } + requestedSleepDay = nil + } observeResultChange() } .sheet(item: $wakeEdit) { edit in @@ -2186,6 +2203,61 @@ struct SleepView: View { // MARK: - Empty / sparse states @ViewBuilder + private var selectedNightRecalculationCard: some View { + NoopCard(tint: StrandPalette.restColor) { + VStack(alignment: .leading, spacing: NoopMetrics.cardInnerSpacing) { + Text("Recalculate sleep").font(StrandFont.headline) + Text("Select the date you woke up. Uses only data already on this device. Imports, manual edits and deleted windows are preserved.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textSecondary) + DatePicker("Wake date", selection: $recalculationDate, + in: ...(Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()), + displayedComponents: .date) + .disabled(recalculatingNight) + .onChangeCompat(of: recalculationDate) { _ in recalculationNotice = nil } + Button("Recalculate sleep") { + guard !recalculatingNight else { return } + let date = recalculationDate + let day = AnalyticsEngine.dayString(Int(date.timeIntervalSince1970), + offsetSec: TimeZone.current.secondsFromGMT(for: date)) + requestedSleepDay = day + recalculatingNight = true + recalculationNotice = nil + recalculationTask = Task { @MainActor in + defer { recalculatingNight = false } + do { + let result = try await intelligence.recalculateSleepNight(day: day) + switch result { + case .completed: + recalculationNotice = String(localized: "Selected day's sleep recalculated from available data.") + case .noSleepDetected: + recalculationNotice = String(localized: "No sleep detected from available data. Saved edits and deleted windows were preserved.") + case .insufficientData: + recalculationNotice = String(localized: "Not enough usable data for this day. Sync the strap, then try again. Recalculation does not fetch data.") + case .busy: + recalculationNotice = String(localized: "Another recalculation is running. Try again when it finishes.") + case .invalidDate: + recalculationNotice = String(localized: "Choose a completed past wake date.") + case .deletionSuppressed: + recalculationNotice = String(localized: "This day includes deleted sleep. Its deletion was preserved; no recalculation was run.") + case .failed: + recalculationNotice = String(localized: "Couldn't recalculate this day. Try again.") + } + } catch is CancellationError { + return + } catch { + recalculationNotice = String(localized: "Couldn't recalculate this day. Try again.") + } + } + } + .disabled(recalculatingNight || intelligence.computing) + if recalculatingNight { ProgressView() } + if let recalculationNotice { + Text(recalculationNotice).font(StrandFont.footnote).foregroundStyle(StrandPalette.textSecondary) + } + } + } + } + private var emptyState: some View { SleepFreshnessNote(latestWakeTs: nil) if repo.loaded { diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 407341f626..24a7fc3365 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -300,6 +300,158 @@ "platform": "kotlin", "rationale": "Swift Repository.refresh reads the same imported daily union (unionDailyMetrics) in the excluded app layer and hands it, still apart from the computed union, to ChargeBaselines.resolve (#2525); this disposition does not assert behavioral parity.", "twin_path": "Strand/Data/Repository.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::analyzeRecent/28#1", + "identity_sha256": "5ac21b9d48f33cd95888f0962e85e7e37239c9e8a7e8ac4d03590329a2b17b53", + "platform": "kotlin", + "rationale": "Selected-night scoring and persistence orchestration has its Swift role twin inside IntelligenceEngine.analyzeRecent and its selectedNightRecoveryUpdates helper in the excluded Apple app target. The Kotlin helper keeps the instrumented scorer below its existing method budget; shared window and dependent-score math are paired separately and covered by production oracles.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::selectedNightRecoveryUpdates/6#1", + "identity_sha256": "efac557f09a27c69b4ef2bac0d99db2a26e668e4cf7e262b80a73535d9b05a00", + "platform": "kotlin", + "rationale": "Selected-night scoring and persistence orchestration has its Swift role twin inside IntelligenceEngine.analyzeRecent and its selectedNightRecoveryUpdates helper in the excluded Apple app target. The Kotlin helper keeps the instrumented scorer below its existing method budget; shared window and dependent-score math are paired separately and covered by production oracles.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::selectedSleepWrites/5#1", + "identity_sha256": "93d12e3c7c7cd28ea8ad9c6cac772467063e4f12438c0a91c8b21ee266fec958", + "platform": "kotlin", + "rationale": "Selected-night scoring and persistence orchestration has its Swift role twin inside IntelligenceEngine.analyzeRecent and its selectedNightRecoveryUpdates helper in the excluded Apple app target. The Kotlin helper keeps the instrumented scorer below its existing method budget; shared window and dependent-score math are paired separately and covered by production oracles.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::scoringEditedRows/7#1", + "identity_sha256": "0965a5d52a7a89f46d018785698d267f3fca8064730b949ba8087e8b3746de57", + "platform": "kotlin", + "rationale": "Selected-night scoring and persistence orchestration has its Swift role twin inside IntelligenceEngine.analyzeRecent and its selectedNightRecoveryUpdates helper in the excluded Apple app target. The Kotlin helper keeps the instrumented scorer below its existing method budget; shared window and dependent-score math are paired separately and covered by production oracles.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::scoringEditedRowsForDay/3#1", + "identity_sha256": "4e62ec038beaaf3313ac4c35c50641f573149902139515b7de99dea4ef070c44", + "platform": "kotlin", + "rationale": "Selected-night scoring and persistence orchestration has its Swift role twin inside IntelligenceEngine.analyzeRecent and its selectedNightRecoveryUpdates helper in the excluded Apple app target. The Kotlin helper keeps the instrumented scorer below its existing method budget; shared window and dependent-score math are paired separately and covered by production oracles.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::selectedNightBaselineHistory/3#1", + "identity_sha256": "18553ba01b1373dd3f89abeedb620e63b8d6f568fc0530eb928c5b67e515f1fe", + "platform": "kotlin", + "rationale": "Selected-night scoring and persistence orchestration has its Swift role twin inside IntelligenceEngine.analyzeRecent and its selectedNightRecoveryUpdates helper in the excluded Apple app target. The Kotlin helper keeps the instrumented scorer below its existing method budget; shared window and dependent-score math are paired separately and covered by production oracles.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::persistDetectedSleepDetails/5#1", + "identity_sha256": "31a4cb40b124b9a6024e5e5335127bba95be671e76b06069b61c6954d7a5e194", + "platform": "kotlin", + "rationale": "Selected-night scoring and persistence orchestration has its Swift role twin inside IntelligenceEngine.analyzeRecent and its selectedNightRecoveryUpdates helper in the excluded Apple app target. The Kotlin helper keeps the instrumented scorer below its existing method budget; shared window and dependent-score math are paired separately and covered by production oracles.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::scoringPreservedSleep/4#1", + "identity_sha256": "435eb41c8a8f0e060dd5a8dcc64393a63ffe837c3b8e7724954ded118dce6359", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "Swift selected-night scoring loads saved user-edited sessions inline in the excluded IntelligenceEngine app layer; Kotlin extracts that read outside its CPU coroutine to preserve the instrumentation budget. The shared analyzer performs the paired authoritative manual-window merge." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::deleteRecoveryProvenance/2#1", + "identity_sha256": "be10b1789a263393272e048b4aa0d3f9c044ecfb9ff6d95f6d1bfe386bad881c", + "platform": "kotlin", + "rationale": "Room requires a separate suspend @Query to delete one recovery provenance cell. Swift executes the identical DELETE inline in the paired WhoopStore.writeSleepDependentRecovery transaction writer; no shared score formula or source policy is exempted." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::seedSelectedNightBaselines/4#1", + "identity_sha256": "49c272b66af498efd502a03ba064f952498be99d432998c7580f83bc31fdc230", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The selected-night baseline/read/transaction preparation is inline in the excluded Swift IntelligenceEngine app layer. Kotlin extracts this app orchestration outside the large scorer coroutine to preserve the unchanged instrumentation budget; shared scoring math remains paired and oracle-tested." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::readDaySkinAndWristOff/13#1", + "identity_sha256": "54d34d0730b740901a4965943c16789371072e60cfb6458e84518d688226abc6", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The selected-night baseline/read/transaction preparation is inline in the excluded Swift IntelligenceEngine app layer. Kotlin extracts this app orchestration outside the large scorer coroutine to preserve the unchanged instrumentation budget; shared scoring math remains paired and oracle-tested." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::selectedScanFoundNoSleep/1#1", + "identity_sha256": "d08fb130670760ce183f4aa5b9bfd7e13256a636733b04a2684c23ac74f9f8af", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The equivalent selected-night write preparation, no-detection guard and bounded weekly-input read are inline in the excluded Swift IntelligenceEngine app layer. Kotlin keeps this orchestration outside its large CPU coroutine to preserve the instrumentation budget; shared detector/scoring/persistence helpers remain paired." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::priorFitnessRows/4#1", + "identity_sha256": "5783b03594451b08aafd84a652c058d87d7f7e64d9d37dfe3f4d7b7bc1b0cfa3", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The equivalent selected-night write preparation, no-detection guard and bounded weekly-input read are inline in the excluded Swift IntelligenceEngine app layer. Kotlin keeps this orchestration outside its large CPU coroutine to preserve the instrumentation budget; shared detector/scoring/persistence helpers remain paired." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::persistSelectedOrRecentWindow/10#1", + "identity_sha256": "7fcbe4b471c3dcb8801dad0a6d98110daf9ce865407b5444a043b110385c50ad", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The equivalent selected-night write preparation, no-detection guard and bounded weekly-input read are inline in the excluded Swift IntelligenceEngine app layer. Kotlin keeps this orchestration outside its large CPU coroutine to preserve the instrumentation budget; shared detector/scoring/persistence helpers remain paired." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::persistComputedWindow/8#1", + "identity_sha256": "25b796e72769523ef2c0aec7fdcd46840bc13d143ff33381232769baaee40e9a", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The equivalent selected-night write preparation, no-detection guard and bounded weekly-input read are inline in the excluded Swift IntelligenceEngine app layer. Kotlin keeps this orchestration outside its large CPU coroutine to preserve the instrumentation budget; shared detector/scoring/persistence helpers remain paired." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::persistInto/6#1", + "identity_sha256": "62c20fcbb2b642f7a2876648348856473b0cb5e60477349e41c3a3e3d67180ec", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The equivalent selected-night write preparation, no-detection guard and bounded weekly-input read are inline in the excluded Swift IntelligenceEngine app layer. Kotlin keeps this orchestration outside its large CPU coroutine to preserve the instrumentation budget; shared detector/scoring/persistence helpers remain paired." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::replaceComputedScoreWindow/6#1", + "identity_sha256": "0fbceed895d693d49dc0e5013e2d04a1d62653a5a37435ad7dacf6110da5ecb8", + "platform": "kotlin", + "twin_path": "Strand/Data/IntelligenceEngine.swift", + "rationale": "The equivalent selected-night write preparation, no-detection guard and bounded weekly-input read are inline in the excluded Swift IntelligenceEngine app layer. Kotlin keeps this orchestration outside its large CPU coroutine to preserve the instrumentation budget; shared detector/scoring/persistence helpers remain paired." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 6d97b4e14d..4f98c7e81a 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,16 +18,16 @@ ] }, "authority": { - "files": {"count": 510, "sha256": "b1c73338e79009d984d1313d8b3166e52672712c68074f5fee190c503e299b18"}, - "functions": {"count": 4538, "sha256": "f62a1ca0dfb2122fdca87a2f455ec0ca4435903c24069ae13bc4e75c0c3f32c0"}, - "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, + "files": {"count": 516, "sha256": "b705cfd9d84d5fbc9e14920390b9efb30ad94e680f67c062c7d407064c97103c"}, + "functions": {"count": 4562, "sha256": "e0b7ef4bd5246eebea3d69bd3b79cc76f9f349359d853143d8cd6b76b034307d"}, + "properties": {"count": 490, "sha256": "7ca493d7f7623d392a1ab316e96bc6409e84a69165045f59e77d9c47aa927a86"}, "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, - "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, + "file_pairs": {"count": 88, "sha256": "d813a542b0296ee7c4668ce158233fc1dc27e5ffb0d939e855fdadbf2343f207"}, + "function_pairs": {"count": 235, "sha256": "5839c366081ff7f22f0295b3f0c6f230b38236fa549002e79c04800d4507cc57"}, + "property_pairs": {"count": 164, "sha256": "c139827df995ffcf0d0658d5a8433f1d1d56bf67b9a9e03e199a94a479818997"}, "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, - "unpaired_files": {"count": 370, "sha256": "441451c8f68b68e1dc3d3556c28952b4bdac243de9d6f909d3c18d6ac9732a1e"}, - "unpaired_functions": {"count": 4099, "sha256": "3ee3e74d901e17225e294c309c1b00a7a5b178bb3164bff43eaaa5309d574169"}, + "unpaired_files": {"count": 368, "sha256": "15e19b71af438e8dd0c9be7879baa9230d05d1b6fbb0ad7dcf686c205b6da311"}, + "unpaired_functions": {"count": 4105, "sha256": "1eff61c7cee0e3b9aa53a1ef1474494bae6a75ff08f98a172b9c26dacec04eee"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 597, "sha256": "578c607cc05b1cd1f867c206c9fab70237ac40707cd49d4e0c5cc66b400184c7"} } diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 66b99085a3..755643119b 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -1,6 +1,7 @@ package com.noop.analytics import com.noop.data.DailyMetric +import com.noop.data.SleepSession import com.noop.data.EventRow import com.noop.data.GravitySample import com.noop.data.V18AuxRow @@ -420,6 +421,8 @@ object AnalyticsEngine { // Threaded rather than read from a global so this stays a pure function, and defaulted so every // existing caller and test is byte-identical. effortMethod: StrainScorer.Method = StrainScorer.Method.EDWARDS, + sleepDayWindow: SleepRecalculationWindow? = null, + preservedSleep: List = emptyList(), ): DayResult { // Precompute the day's UTC bounds ONCE (#996). isoDay is a FIXED-UTC formatter, so @@ -430,7 +433,13 @@ object AnalyticsEngine { // incl. fractional offsets), matching the Swift twin. val dayStartUtc = dayStartUtcSeconds(day) val dayEndUtc = dayStartUtc + 86_400 - fun tsInDay(ts: Long): Boolean = (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc + fun tsInDay(ts: Long): Boolean { + if (sleepDayWindow != null) { + require(sleepDayWindow.day == day) + return sleepDayWindow.contains(ts) + } + return (ts + tzOffsetSeconds) >= dayStartUtc && (ts + tzOffsetSeconds) < dayEndUtc + } // ── Sleep detection + staging ───────────────────────────────────────── val detectedSessions = SleepStager.detectSleep( @@ -444,7 +453,7 @@ object AnalyticsEngine { // night-window stream the caller passed for the rest of this analysis; the pass self-gates on its // observed density, so an empty/sparse `steps` (e.g. a WHOOP 4.0, which never emits a step sample // at all) is a no-op regardless of `useMotionAwareWake`. - val refinedSessions = if (useMotionAwareWake) { + val refined = if (useMotionAwareWake) { detectedSessions.map { WakeMotionRefinement.refine(it, gravity, steps) } } else { detectedSessions @@ -454,11 +463,17 @@ object AnalyticsEngine { // from THIS day's hr/rr over its window (the stored ring row carries neither), using the SAME helpers // detectSleep populates a session with, then keep only the detected sessions that DON'T overlap a // provided one (provided is authoritative where they collide; a separate nap survives). - val allSessions: List = if (providedSleep.isEmpty()) { + val refinedSessions = refined.filter { detected -> + preservedSleep.none { it.effectiveStartTs < detected.end && detected.start < it.endTs } + } + val allProvidedSleep = providedSleep.filter { provided -> + preservedSleep.none { it.effectiveStartTs < provided.end && provided.start < it.endTs } + } + preservedSleep.mapNotNull { sleepSessionFromProvided(it) } + val allSessions: List = if (allProvidedSleep.isEmpty()) { refinedSessions } else { val rrSorted = rr.sortedBy { it.ts } - val enrichedProvided = providedSleep.map { s -> + val enrichedProvided = allProvidedSleep.map { s -> // #1884: no HR-only special case any more. This used to short-circuit on `s.hrOnly` to // preserve #1801's display-only guarantee, which withheld both values. Now that an HR-only // session reports what it measured, that clause is not merely redundant — an HR-only night diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 9683d3f290..2846e47890 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -125,6 +125,11 @@ object IntelligenceEngine { // Guarded by analyzeGate; kept as state to avoid another parameter on the bytecode-budgeted pass. private var preserveUnscoredHistoryForRun = false + // Guarded by analyzeGate and overwritten at the beginning of every scoring pass. + private var selectedNightForRun: SleepRecalculationWindow? = null + private var selectedBaselineHistoryForRun: List = emptyList() + private var selectedSleepWritesForRun: List? = null + /** One reused night: its per-day cache [key], the scored [res], and everything the pass-1 loop otherwise * writes into function-scoped per-day maps that pass 2 reads (owner/hrRows/primary-session RHR/SpO₂ * candidate/HRV over-count), plus the always-on per-day [diagLines] to replay so a reused pass logs the @@ -503,9 +508,11 @@ object IntelligenceEngine { stepsMotionCacheGet: (() -> String?)? = null, stepsMotionCacheSet: ((String) -> Unit)? = null, preserveUnscoredHistory: Boolean = false, + selectedNight: SleepRecalculationWindow? = null, ): List = withContext(Dispatchers.Default) { // #1005: time the whole pass so a re-score STORM is visible in the strap log (the trigger lines // record WHY each pass runs; this records how many nights and how long — the CPU cost per run). + require(selectedNight == null || (maxDays == 1 && nowSeconds == selectedNight.end - 1)) val reScoreStart = System.nanoTime() // Serialise the whole pass so overlapping callers never run two rescores in parallel (see // [analyzeGate]). The heavy scoring already ran off the caller's thread via withContext above; the @@ -520,6 +527,12 @@ object IntelligenceEngine { // across the back-to-back passes an offload storm is made of. Reset and emit both live in this // wrapper, never in `analyzeRecentOnCpu`, whose ratchet margin has no room for either. StoreProbeTally.reset() + selectedNightForRun = selectedNight + selectedBaselineHistoryForRun = if (selectedNight == null) emptyList() else repo.dailyMetrics( + importedDeviceId + "-noop", AnalyticsEngine.dayString(selectedNight.start - 20 * SECONDS_PER_DAY, selectedNight.offsetSeconds), selectedNight.day) + + selectedSleepWritesForRun = null + if (selectedNight != null) dayScanCache.remove(selectedNight.day) preserveUnscoredHistoryForRun = preserveUnscoredHistory if (!stepsMotionCacheLoaded && stepsMotionCacheGet != null) { stepsMotionCacheLoaded = true @@ -689,7 +702,7 @@ object IntelligenceEngine { // the current DST state (a DST boundary inside a single window is a negligible edge case // for an hour-of-day band). Computed once per run. val tzOffsetSeconds = - java.util.TimeZone.getDefault().getOffset(nowSeconds * 1_000L) / 1_000L + selectedNightForRun?.offsetSeconds ?: (java.util.TimeZone.getDefault().getOffset(nowSeconds * 1_000L) / 1_000L) // Device-registry snapshot for per-day owner resolution (invariant I2 , a day's scores come from // exactly ONE source). Read ONCE before the loop: the paired-device list is stable for the run. @@ -725,7 +738,7 @@ object IntelligenceEngine { // (SleepStager + AnalyticsEngine), so we harvest them to SEED the baseline and // re-score in pass 2. Collected oldest-first to match foldHistory's replay order. // foldHistory winsorizes outliers. days() is oldest-first (Swift ascending). - val hist = repo.days(importedDeviceId) + val hist = selectedNightBaselineHistory(repo, importedDeviceId, tzOffsetSeconds) // CAPTURE-B: per-day resolved read owner + HR-row count, captured in pass 1, consumed by pass 2's // universal dayOwner emit (which reuses the SAME importedWhoopDays / appleHealthDays sets pass 2 // builds for daySourceToken, so there is no extra read). Only populated when the universal sink is @@ -772,7 +785,7 @@ object IntelligenceEngine { // day keys are LOCAL calendar days, consistent with the dashboard's local "today" lookup. A // west-of-UTC user's evening crosses midnight UTC; bucketing by UTC put it in the next UTC day, // which the local read never found (Toronto/UTC-4 report). - val nowLocalMidnight = midnightLocal(nowSeconds, tzOffsetSeconds) + val nowLocalMidnight = selectedNightForRun?.start ?: midnightLocal(nowSeconds, tzOffsetSeconds) // ── Learned habitual midsleep (#547) ────────────────────────────────── // Compute the user's habitual midsleep ONCE per run from the trailing sleep history so the @@ -785,7 +798,7 @@ object IntelligenceEngine { // the Sleep tab resolve to the identical block. Mirrors Swift. (#547) val (habitualMidsleepSec, nightlyHours) = computeHabitualSleep( repo, importedDeviceId, computedId, - nowLocalMidnight - maxDays * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS, nowSeconds, + nowLocalMidnight - (if (selectedNightForRun == null) maxDays else 21) * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS, nowSeconds, finishedBefore = nowLocalMidnight, offsetSec = tzOffsetSeconds, ) // Wave 0 (SL1/T1): personal sleep REGULARITY + population-anchored NEED, computed ONCE from the @@ -918,7 +931,7 @@ object IntelligenceEngine { // the future), NOT a fixed `dayStart + 18h` — that cap reported a flat 18:00 wake for a // day-sleeper (still inside today when they wake) until local midnight flipped the day to // past and it silently re-scored (#500 follow-up). Byte-twin of the Swift window. - val to = sleepReadWindowEnd(dayStart, nowLocalMidnight, nowSeconds) + val to = selectedNightForRun?.let { it.end - 1 } ?: sleepReadWindowEnd(dayStart, nowLocalMidnight, nowSeconds) // I2: pick the single device that OWNS this day, and read ITS streams below. With one device // this resolves to [importedDeviceId] (active strap, has data → priority 0), so nothing @@ -1043,7 +1056,7 @@ object IntelligenceEngine { val steps = repo.stepSamples(owner, from, to, STREAM_LIMIT) val skinReads = readDaySkinAndWristOff( repo, owner, from, to, ownerSource, skinFamilyByOwner, skinWornToleranceByOwner, - skinAnchorByOwner, skinAnchorResolvedOwners, skinAnchorScanFrom, skinAnchorScanTo, hr, + skinAnchorByOwner, skinAnchorResolvedOwners, skinAnchorScanFrom, skinAnchorScanTo, hr, computedId, ) val skin = skinReads.skin val spo2 = skinReads.spo2 @@ -1061,7 +1074,7 @@ object IntelligenceEngine { // `dayStart` is already a LOCAL midnight; midnightLocal is idempotent on it (the DAO range // is inclusive, so end at +86400-1s; analyzeDay also filters to the day). (#277) val dayMidnight = midnightLocal(dayStart, tzOffsetSeconds) - val dayEnd = dayMidnight + SECONDS_PER_DAY - 1 + val dayEnd = selectedNightForRun?.let { it.end - 1 } ?: (dayMidnight + SECONDS_PER_DAY - 1) // Same [owner] as the night window above (I2): the additive day totals must come from the one // device that owns the day, never a mix. // #997: for a PAST day the [from, to] night read above already spans this calendar day (to = @@ -1208,6 +1221,8 @@ object IntelligenceEngine { hrvWindowDetail = dayStart == nowLocalMidnight, deepHrvWindow = deepHrvWindow, effortMethod = effortMethod, + sleepDayWindow = selectedNightForRun, + preservedSleep = skinReads.preservedSleep, ) dayScoreNanos += System.nanoTime() - tScore0 @@ -1467,6 +1482,7 @@ object IntelligenceEngine { dayCacheCacheable++ } } + if (selectedNightForRun != null && scoredNights.isEmpty()) return emptyList() to 0 // #1005: prune the reuse cache to the current window (the oldest day ages out at midnight) and log a // one-line reuse count on the same diag channel as the per-day lines. val dayCacheWindow = (0 until maxDays) @@ -1509,6 +1525,8 @@ object IntelligenceEngine { // is the scan window's own 21 days, so the own nights are exactly this pass's in-memory pass-1 values // (a full-history repair pass scores more days; the window trims it to the same 21), and a BLE-only // user folds the same nights as before. Mirrors Swift. + seedSelectedNightBaselines( + nightlyHrvByDay, nightlyRhrByDay, nightlyRespByDay, nightlySkinByDay) val oldestDay = AnalyticsEngine.dayString( nowLocalMidnight - (maxDays - 1) * SECONDS_PER_DAY, tzOffsetSeconds, ) @@ -1587,7 +1605,7 @@ object IntelligenceEngine { // the cross-source duplicate (#107): the strap source carries imported WHOOP rows AND manual / // re-labelled rows (both under [importedDeviceId]); apple-health / health-connect carry Health // imports , a detected bout overlapping ANY of them is skipped below. - val windowStart = nowSeconds - maxDays.toLong() * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS + val windowStart = selectedNightForRun?.readStart ?: (nowSeconds - maxDays.toLong() * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS) val realWorkouts = repo.workouts(importedDeviceId, windowStart, nowSeconds) + repo.workouts("apple-health", windowStart, nowSeconds) + repo.workouts("health-connect", windowStart, nowSeconds) @@ -1626,15 +1644,9 @@ object IntelligenceEngine { // daily aggregate this same pass. A no-op for nights already staged from raw (idempotent) and for // imported nights (raw never dense). MUST run before `editsByStart` so healed stages flow into // Rest/recovery this run. Raw streams are read under the STRAP id; edited rows under COMPUTED. - val editedRows = SleepStageHealer.selfHealEditedStages( - repo = repo, - computedDeviceId = computedId, - strapDeviceId = importedDeviceId, - windowStart = windowStart, - windowEnd = nowSeconds, - useExperimentalSleepV2 = useExperimentalSleepV2, - useMotionAwareWake = useMotionAwareWake, - ) + val editedRows = scoringEditedRows(repo, computedId, importedDeviceId, windowStart, nowSeconds, + useExperimentalSleepV2, useMotionAwareWake) + // #299: [editsByStart] / [editOnsetByStart] are now built PER DAY inside the scoring loop (scoped to // the day each edit belongs to), NOT window-wide here. sleepEditedDaily folds any edited row that // isn't a twin of THIS day's detected sessions in as a "manual" block, so a window-wide edit set let @@ -1650,7 +1662,7 @@ object IntelligenceEngine { // source priority. Mirrors the Swift `importedWhoopDays` / `appleHealthDays` sets. val importedWhoopDays = hist.map { it.day }.toHashSet() val appleHealthDays = repo - .appleDaily(WhoopRepository.APPLE_HEALTH_SOURCE, "0000-01-01", "9999-12-31") + .appleDaily(WhoopRepository.APPLE_HEALTH_SOURCE, selectedNightForRun?.day ?: "0000-01-01", selectedNightForRun?.day ?: "9999-12-31") .map { it.day }.toHashSet() val physiologicalSteps = DayCycleIntelligenceIntegration.compute( @@ -1668,7 +1680,7 @@ object IntelligenceEngine { // here keeps a single-night edit overriding only its OWN night instead of every night. The // #547 effective-onset detail is preserved: editOnsetByStart still carries the user-CORRECTED // bedtime (startTsAdjusted ?: startTs) for this day's edited/manual blocks. - val dayEditedRows = editedRowsForDay(editedRows, res.daily.day, tzOffsetSeconds) + val dayEditedRows = scoringEditedRowsForDay(editedRows, res.daily.day, tzOffsetSeconds) // Substitute an edited block's (reshaped) stages for its detected twin before the daily // sleep aggregate feeds Rest + recovery. No edit touching this night → `daily` is unchanged. val editedDaily = editedCycleDaily( @@ -1933,7 +1945,7 @@ object IntelligenceEngine { // OWN pruning , a recompute only re-scores nights whose raw HR still lives in the store, so reading // after the rewrite would see only the freshly scorable subset. Windowed to the recompute range so // it stays bounded (daysMerged is full-history) and can't drag in stale nights older than the window. - val faPriorDaily = repo.daysMerged(importedDeviceId).filter { it.day in oldestDay..newestDay } + val faPriorDaily = priorFitnessRows(repo, importedDeviceId, oldestDay, newestDay) // Persist the computed scores under the dedicated "-noop" source so the WHOLE // dashboard (Today / Recovery / Strain / Sleep / Trends) reads them. The repository @@ -1941,14 +1953,17 @@ object IntelligenceEngine { // this only fills the days the strap collected but no import covered. // Persist metric-level input provenance in the SAME Room transaction. dayOwnership remains // exclusively a resolver override, and a failed write can never relabel an older score. + if (selectedScanFoundNoSleep(out)) return out to 0 + val computedWindow = IntelligencePersistence.prepareComputedWindow( repo, importedDeviceId, computedId, oldestDay, newestDay, dailies, restRows, physiologicalSteps, candidatePriorities, resolvedScoreOwnerByDay, IntelligencePersistence.LegacyScoreClock(nowLocalMidnight, nowSeconds, tzOffsetSeconds), out, ) - IntelligencePersistence.persistComputedWindow(repo, computedWindow, preserveUnscoredHistoryForRun) + persistSelectedOrRecentWindow(repo, computedWindow, importedDeviceId, baselineEpoch, recoveryEpoch, + resolvedScoreOwnerByDay, ownerSource, sleepRows, editedRows, scoredNights) - persistFitnessVitalityAndSteps( + if (selectedNightForRun == null) persistFitnessVitalityAndSteps( repo = repo, dailies = dailies, faPriorDaily = faPriorDaily, @@ -1984,8 +1999,9 @@ object IntelligenceEngine { // the JVM-tested twin of Swift's DismissedSleepSpans. val dismissedWindows = repo.dismissedSleeps(importedDeviceId).map { it.startTs to it.endTs } val skipWindows = editedWindows + dismissedWindows - val sleepKept = DismissedSleepGuard.keeping(sleepRows, skipWindows) { it.startTs to it.endTs } - IntelligencePersistence.persistDetectedSleepDetails(repo, computedId, sleepKept, scoredNights) + val sleepKept = selectedSleepWritesForRun ?: DismissedSleepGuard.keeping(sleepRows, skipWindows) { it.startTs to it.endTs } + IntelligencePersistence.persistDetectedSleepDetails(repo, computedId, sleepKept, scoredNights, + alreadyPersisted = selectedNightForRun != null) val keptStarts = sleepKept.map { it.startTs }.toHashSet() // ── Overlap-aware banked-sleep heal (#899) ──────────────────────────────────────────────────── // An unstable strap clock re-banks the SAME night under a shifted timebase, so successive passes @@ -2014,7 +2030,8 @@ object IntelligenceEngine { // row read at 04:14, so the day ended at 04:48 instead of 08:21. `SleepSessionDedup.healWitness` is // the one shared rule (twin of Swift's). Sorted for a deterministic order. Mirrors the Swift // analyzeRecent heal. - val healDeviceIds = healDeviceIds(computedId, candidatePriorities.map { it.first }) + val healDeviceIds = if (selectedNightForRun != null) emptyList() + else healDeviceIds(computedId, candidatePriorities.map { it.first }) // Compact shape of a row for the #1284 heal log — the two measures that adjudicate WHICH copy is // fuller (stage-segment count + decoded JSON length), in the SAME format as the dup-gen diagnostic // (dupGenShape) so the two lines parse identically. Window + shape counts only, never stage content @@ -2028,7 +2045,7 @@ object IntelligenceEngine { for (healId in healDeviceIds) { val storedSessions = repo.sleepSessionsForDevice(healId, windowStart, nowSeconds, 4000) val healable = storedSessions.filter { - AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) in oldestDay..newestDay + selectedNightForRun?.contains(it.endTs) ?: (AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) in oldestDay..newestDay) } val witness = SleepSessionDedup.healWitness(healId, computedId, keptStarts) val sweep = SleepSessionDedup.dedupe(healable, freshStarts = witness) @@ -2062,7 +2079,7 @@ object IntelligenceEngine { // history and no longer inserts fresh generic rows. Only write real rows whose missing metrics were // enriched above. Turning detection off, a partial stream, or a failed/cancelled pass is therefore // incapable of erasing previously visible workouts. - repo.persistWorkoutBackfills(workoutBackfills) + if (selectedNightForRun == null) repo.persistWorkoutBackfills(workoutBackfills) // #137: a manually-started workout is scored from sparse live HR at save time , near-zero // calories/strain on a 5/MG. Now that offloaded HR may cover the window, re-score the @@ -2076,12 +2093,116 @@ object IntelligenceEngine { // so out[0] is today and the tail is the oldest day in the window. Taking the last match would have // scored today's workout against a resting HR up to `maxDays` old. val measuredResting = out.firstOrNull { it.rhr != null }?.rhr?.toDouble() - rescoreManualWorkouts(repo, profile, importedDeviceId, maxHROverride, nowSeconds, + if (selectedNightForRun == null) rescoreManualWorkouts(repo, profile, importedDeviceId, maxHROverride, nowSeconds, measuredResting, effortMethod) return out to healDropped.size } + private fun selectedScanFoundNoSleep(rows: List): Boolean = + selectedNightForRun != null && rows.all { (it.sleepMin ?: 0.0) <= 0.0 } + + private suspend fun priorFitnessRows(repo: WhoopRepository, importedId: String, from: String, to: String): List = + if (selectedNightForRun == null) repo.daysMerged(importedId).filter { it.day in from..to } else emptyList() + + private suspend fun persistSelectedOrRecentWindow( + repo: WhoopRepository, window: IntelligencePersistence.ComputedWindow, importedId: String, + hrvEpoch: Double, recoveryEpoch: Double, ownerByDay: Map, + ownerSource: DayOwnerSource?, sleepRows: List, edits: List, scoredNights: List, + ) { + selectedSleepWritesForRun = selectedSleepWrites(repo, importedId, ownerSource, sleepRows, edits) + val updates = selectedNightRecoveryUpdates(repo, window, importedId, hrvEpoch, recoveryEpoch, ownerByDay) + val selected = selectedNightForRun + val motion = if (selected == null) emptyMap() else scoredNights.flatMap { it.sessionMotionByStart.entries }.associate { it.toPair() } + val states = if (selected == null) emptyMap() else scoredNights.flatMap { it.sessionSleepStateByStart.entries }.associate { it.toPair() } + IntelligencePersistence.persistComputedWindow(repo, window, preserveUnscoredHistoryForRun, + selectedSleepWritesForRun, updates, motion, states, selected?.let { it.start until it.end }) + } + + private suspend fun selectedNightRecoveryUpdates( + repo: WhoopRepository, window: IntelligencePersistence.ComputedWindow, importedId: String, + hrvEpoch: Double, recoveryEpoch: Double, ownerByDay: Map, + ): List { + val selected = selectedNightForRun ?: return emptyList() + val today = java.time.LocalDate.now().toString() + val from = AnalyticsEngine.dayString(AnalyticsEngine.dayStartUtcSeconds(today) - 20 * SECONDS_PER_DAY) + if (selected.day < from) return emptyList() + val to = today + val imported = repo.dailyMetrics(importedId, from, to) + val stored = repo.dailyMetrics(importedId + "-noop", from, to) + val old = stored.firstOrNull { it.day == selected.day } + val updated = window.dailies.firstOrNull { it.day == selected.day } + var baselineChanged = old == null || old.avgHrv != updated?.avgHrv || old.restingHr != updated?.restingHr || + old.respRateBpm != updated?.respRateBpm || old.skinTempC != updated?.skinTempC + val oldSource = repo.scoreInputSource(importedId + "-noop", selected.day, "strain") + ownerByDay[selected.day]?.let { if (it != oldSource) baselineChanged = true } + val own = stored.filter { it.day != selected.day } + window.dailies + val sources = ownerByDay.toMutableMap() + for (row in own) { + if (row.day !in sources) { + repo.scoreInputSource(importedId + "-noop", row.day, "strain")?.let { sources[row.day] = it } + } + } + return SleepRecalculationDependents.updates(selected.day, today, baselineChanged, imported, own, sources, importedId, hrvEpoch, recoveryEpoch) + .filter { it.day !in window.retainedLegacyDays } + .map { update -> + // A dependent day still uses its saved R-R input; never relabel it as the active strap. + val source = if (update.day == selected.day) update.sourceId else repo.scoreInputSource( + importedId + "-noop", update.day, "recovery") + update.copy(sourceId = source) + } + } + + private suspend fun scoringPreservedSleep(repo: WhoopRepository, computedId: String, from: Long, to: Long): List { + val window = selectedNightForRun ?: return emptyList() + return repo.sleepSessionsForDevice(computedId, from, to).filter { it.userEdited && window.contains(it.endTs) } + } + + private suspend fun selectedSleepWrites( + repo: WhoopRepository, importedId: String, ownerSource: DayOwnerSource?, sleepRows: List, edits: List, + ): List? { + val window = selectedNightForRun ?: return null + val dismissed = repo.dismissedSleepsUnion(ownerSource?.activeWriteId() ?: importedId) + check(dismissed.none { it.startTs < window.end && it.endTs >= window.start }) { + "Sleep deletion changed during selected-night scoring." + } + return DismissedSleepGuard.keeping(sleepRows, + edits.map { it.effectiveStartTs to it.endTs } + dismissed.map { it.startTs to it.endTs }) { + it.startTs to it.endTs + } + } + + private suspend fun scoringEditedRows( + repo: WhoopRepository, computedId: String, importedId: String, from: Long, to: Long, + useExperimentalSleepV2: Boolean, useMotionAwareWake: Boolean, + ): List = if (selectedNightForRun != null) { + repo.sleepSessionsForDevice(computedId, from, to).filter { it.userEdited } + } else SleepStageHealer.selfHealEditedStages(repo, computedId, importedId, from, to, + useExperimentalSleepV2, useMotionAwareWake) + + private fun scoringEditedRowsForDay(rows: List, day: String, offset: Long): List = + selectedNightForRun?.let { window -> rows.filter { window.contains(it.endTs) && AnalyticsEngine.sleepSessionFromProvided(it) == null } } + ?: editedRowsForDay(rows, day, offset) + + private suspend fun selectedNightBaselineHistory(repo: WhoopRepository, importedId: String, offset: Long): List { + val window = selectedNightForRun ?: return repo.days(importedId) + return repo.dailyMetrics(importedId, AnalyticsEngine.dayString(window.start - 20 * SECONDS_PER_DAY, offset), window.day) + } + + private fun seedSelectedNightBaselines( + hrv: MutableMap, rhr: MutableMap, + resp: MutableMap, skin: MutableMap, + ) { + val window = selectedNightForRun ?: return + for (row in selectedBaselineHistoryForRun) { + if (row.day == window.day) continue + hrv[row.day] = row.avgHrv + rhr[row.day] = row.restingHr?.toDouble() + resp[row.day] = row.respRateBpm + skin[row.day] = row.skinTempC + } + } + private suspend fun persistFitnessVitalityAndSteps( repo: WhoopRepository, dailies: List, @@ -3045,6 +3166,7 @@ object IntelligenceEngine { skinAnchorScanFrom: Long, skinAnchorScanTo: Long, hr: List, + computedId: String, ): DaySkinReads { val skin = repo.skinTempSamples(owner, from, to, StreamReadCap.SKIN) // #93: WHOOP 4.0 raw SpO2 PPG samples for the night; analyzeDay banks the nightly red/IR ADC @@ -3091,7 +3213,8 @@ object IntelligenceEngine { // short off-wrist tail survives. Pairing needs WRIST_ON too (to bound each interval); a span // still open at the window end closes at `to`. Empty when the strap emitted no wrist events. val wristOff = AnalyticsEngine.offWristIntervals(repo.events(owner, from, to, STREAM_LIMIT), to, hr) - return DaySkinReads(skin, spo2, skinFamily, skinWornToleranceSec, skinAnchorRaw, wristOff) + return DaySkinReads(skin, spo2, skinFamily, skinWornToleranceSec, skinAnchorRaw, wristOff, + scoringPreservedSleep(repo, computedId, from, to)) } /** What [readDaySkinAndWristOff] hands back. A holder rather than loose returns so the call site @@ -3103,6 +3226,7 @@ object IntelligenceEngine { val skinWornToleranceSec: Long, val skinAnchorRaw: Double?, val wristOff: List>, + val preservedSleep: List, ) /** diff --git a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt index 52a52e523f..79b15362e6 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt @@ -33,16 +33,24 @@ internal object IntelligencePersistence { val metricRows: List, val provenance: List, val markerSourceIds: List, + val retainedLegacyDays: Set = emptySet(), ) /** Keep the per-day repair loop outside the instrumented scoring coroutine's bytecode budget. */ - suspend fun persistComputedWindow(repo: WhoopRepository, window: ComputedWindow, preserveUnscoredHistory: Boolean) { + suspend fun persistComputedWindow(repo: WhoopRepository, window: ComputedWindow, preserveUnscoredHistory: Boolean, + sleepSessions: List? = null, + dependentRecoveryUpdates: List = emptyList(), + sleepMotion: Map> = emptyMap(), sleepStates: Map> = emptyMap(), + selectedSleepBounds: LongRange? = null) { val windows = if (preserveUnscoredHistory) byScoredDay(window) else listOf(window) - for (part in windows) part.persistInto(repo) + for (part in windows) part.persistInto(repo, sleepSessions, dependentRecoveryUpdates, sleepMotion, sleepStates, selectedSleepBounds) } - private suspend fun ComputedWindow.persistInto(repo: WhoopRepository) = - repo.replaceComputedScoreWindow(this) + private suspend fun ComputedWindow.persistInto(repo: WhoopRepository, sleepSessions: List? = null, + updates: List = emptyList(), + sleepMotion: Map> = emptyMap(), sleepStates: Map> = emptyMap(), + selectedSleepBounds: LongRange? = null) = + repo.replaceComputedScoreWindow(this, sleepSessions, updates, sleepMotion, sleepStates, selectedSleepBounds) /** Per-day repair writes leave older, unscorable cached days and their provenance intact. */ fun byScoredDay(window: ComputedWindow): List { @@ -86,6 +94,7 @@ internal object IntelligencePersistence { metricRows = (metricRows + cycle.recoveredOwnerMarkerRows) .distinctBy { Triple(it.deviceId, it.day, it.key) }, provenance = provenance, + retainedLegacyDays = snapshots.keys, markerSourceIds = DayCycleIntelligenceIntegration.markerRewriteSourceIds( repo.computedSourceIds(importedDeviceId), candidatePriorities.map { (owner, _) -> repo.computedDeviceId(owner) }, @@ -185,9 +194,11 @@ internal object IntelligencePersistence { computedId: String, kept: List, scoredNights: List, + alreadyPersisted: Boolean = false, ) { // Only kept (not edited/dismissed) sessions receive their per-epoch motion and band-state arrays. // Missing streams remain absent rather than being materialised as synthetic zero arrays. + if (alreadyPersisted) return if (kept.isNotEmpty()) repo.upsertSleepSessions(kept) val keptStarts = kept.map { it.startTs }.toHashSet() val motionByStart = HashMap>() @@ -208,6 +219,10 @@ internal object IntelligencePersistence { /** Keep the oversized scoring orchestrator at a single, auditable transactional call site. */ internal suspend fun WhoopRepository.replaceComputedScoreWindow( window: IntelligencePersistence.ComputedWindow, + sleepSessions: List? = null, + updates: List = emptyList(), + sleepMotion: Map> = emptyMap(), sleepStates: Map> = emptyMap(), + selectedSleepBounds: LongRange? = null, ) = replaceComputedScoreWindow( deviceId = window.deviceId, from = window.from, @@ -217,4 +232,7 @@ internal suspend fun WhoopRepository.replaceComputedScoreWindow( provenance = window.provenance, replaceMetricKeys = listOf(DayCycleIntelligenceIntegration.ONSET_KEY), replaceMetricSourceIds = window.markerSourceIds, + sleepSessions = sleepSessions, + dependentRecoveryUpdates = updates, + sleepMotion = sleepMotion, sleepStates = sleepStates, selectedSleepBounds = selectedSleepBounds, ) diff --git a/android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt b/android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt new file mode 100644 index 0000000000..3ff35d92d9 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt @@ -0,0 +1,45 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric +import com.noop.data.SleepRecoveryUpdate + +/** Refresh only stored Charge cells whose existing 21-day baseline can include the selected night. */ +object SleepRecalculationDependents { + /** Swift twin: `SleepRecalculationDependents.updates`. */ + fun updates(selectedDay: String, anchorDay: String, refreshBaseline: Boolean, imported: List, computed: List, + sourceByDay: Map, importedSource: String, + hrvEpoch: Double, recoveryEpoch: Double): List { + val selected = Baselines.isoEpochDay(selectedDay) ?: return emptyList() + val anchor = Baselines.isoEpochDay(anchorDay) ?: return emptyList() + if (anchor < selected || anchor - selected >= ChargeBaselines.windowDays) return emptyList() + return computed.sortedBy { it.day }.mapNotNull { row -> + val day = Baselines.isoEpochDay(row.day) ?: return@mapNotNull null + if (day > anchor || anchor - day >= ChargeBaselines.windowDays || (!refreshBaseline && row.day != selectedDay)) return@mapNotNull null + val hrv = row.avgHrv ?: return@mapNotNull null + val rhr = row.restingHr ?: return@mapNotNull null + val resolved = ChargeBaselines.resolve(imported, computed, anchorDay, hrvEpoch, recoveryEpoch) + val sourceDays = (imported + computed).filter { it.day <= anchorDay }.map { it.day }.distinct().sorted() + val respEpoch = maxOf(recoveryEpoch, Baselines.deviceEraEpoch( + sourceDays.map { it to (sourceByDay[it] ?: importedSource) })) + val respBase = Baselines.foldHistory(resolved.respHistory.values, resolved.respHistory.dayKeys, + Baselines.respCfg, respEpoch) + val skinCfg = Baselines.metricCfg.getValue("skin_temp") + val skinHistory = ChargeBaselines.history(emptyList(), computed.map { it.day to it.skinTempC }, + anchorDay, skinCfg, recoveryEpoch) + val skinBase = Baselines.foldHistory(skinHistory.values, skinHistory.dayKeys, skinCfg, recoveryEpoch) + val skinDev = row.skinTempC?.let { mean -> + if (!skinBase.usable) null else { + val scaled = Baselines.deviation(mean, skinBase).delta * 100.0 + (if (scaled >= 0) Math.floor(scaled + 0.5) else Math.ceil(scaled - 0.5)) / 100.0 + } + } ?: if (row.skinTempC == null) row.skinTempDevC else null + val restQuality = RestScorer.restFromDaily(row)?.let { it / 100.0 } ?: row.efficiency + val recovery = if (resolved.hrv.usable) RecoveryScorer.recovery( + hrv = hrv, rhr = rhr.toDouble(), resp = row.respRateBpm, + hrvBaseline = resolved.hrv, rhrBaseline = resolved.restingHR.takeIf { it.usable }, + respBaseline = respBase.takeIf { it.usable }, sleepPerf = restQuality, skinTempDev = skinDev, + ) else null + SleepRecoveryUpdate(row.day, recovery, skinDev, sourceByDay[row.day]).takeIf { it.changes(row) } + } + } +} diff --git a/android/app/src/main/java/com/noop/analytics/SleepRecalculationWindow.kt b/android/app/src/main/java/com/noop/analytics/SleepRecalculationWindow.kt new file mode 100644 index 0000000000..c5ff6d0250 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SleepRecalculationWindow.kt @@ -0,0 +1,30 @@ +package com.noop.analytics + +import java.time.LocalDate +import java.time.ZoneId + +/** A completed local wake day, with calendar midnights and the scorer's neighboring context. */ +data class SleepRecalculationWindow private constructor( + val day: String, + val start: Long, + val end: Long, + val offsetSeconds: Long, +) { + val readStart: Long get() = start - StreamReadCap.LOOKBACK_SECONDS + + /** Swift twin: `SleepRecalculationWindow.contains`. */ + fun contains(timestamp: Long): Boolean = timestamp >= start && timestamp < end + + companion object { + /** Swift twin: `SleepRecalculationWindow.make`. */ + fun make(day: String, timeZone: ZoneId, now: Long): SleepRecalculationWindow? = runCatching { + val date = LocalDate.parse(day) + require(date.toString() == day && day.length == 10 && date.year > 0) + val midnight = date.atStartOfDay(timeZone) + val start = midnight.toEpochSecond() + val end = date.plusDays(1).atStartOfDay(timeZone).toEpochSecond() + require(start < end && end <= now) + SleepRecalculationWindow(day, start, end, midnight.offset.totalSeconds.toLong()) + }.getOrNull() + } +} diff --git a/android/app/src/main/java/com/noop/data/SleepRecoveryUpdate.kt b/android/app/src/main/java/com/noop/data/SleepRecoveryUpdate.kt new file mode 100644 index 0000000000..617678a6dc --- /dev/null +++ b/android/app/src/main/java/com/noop/data/SleepRecoveryUpdate.kt @@ -0,0 +1,8 @@ +package com.noop.data + +/** A bounded derived-score update that preserves sleep records and other stored day fields. */ +data class SleepRecoveryUpdate(val day: String, val recovery: Double?, val skinTempDevC: Double?, val sourceId: String? = null) { + /** Swift twin: `SleepRecoveryUpdate.changes`. */ + fun changes(row: DailyMetric): Boolean = day == row.day && + (recovery != row.recovery || skinTempDevC != row.skinTempDevC) +} diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 8b0f038d34..8fc5a4fdbf 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -469,6 +469,11 @@ interface WhoopDao : DeviceRegistryDao { @Upsert suspend fun upsertScoreInputProvenance(rows: List) + // Swift selected dependent-recovery writer performs this exact cell deletion inline. + @Query("DELETE FROM scoreInputProvenance WHERE deviceId = :deviceId AND day = :day AND key = 'recovery'") + suspend fun deleteRecoveryProvenance(deviceId: String, day: String) + + @Query( "SELECT sourceId FROM scoreInputProvenance " + "WHERE deviceId = :deviceId AND day = :day AND key = :key" @@ -499,6 +504,9 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun deleteMetricSeriesKeyInRange(deviceId: String, from: String, to: String, key: String) + @Query("UPDATE dailyMetric SET recovery = :recovery, skinTempDevC = :skinTempDevC WHERE deviceId = :deviceId AND day = :day") + suspend fun updateSleepDependentRecovery(deviceId: String, day: String, recovery: Double?, skinTempDevC: Double?) + /** * Replace a computed scoring window atomically. If any score or provenance write fails, Room rolls * the whole transaction back, so an old score can never be labelled with a newer provider. VO₂max's diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 3eba037006..3343f84c9a 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.withTransaction import com.noop.protocol.DroppedRtcEvent import com.noop.protocol.RrSourceChannel +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -766,16 +767,20 @@ class WhoopRepository( */ suspend fun upsertSleepSessions(sessions: List) { if (sessions.isEmpty()) return - transactor.run { - for (candidate in sessions) { - val existing = dao.sleepSession(candidate.deviceId, candidate.startTs) - if (existing == null) { - dao.insertSleepSession(candidate) - } else { - SleepSessionUpsertPolicy.merge(existing, candidate)?.let { - dao.updateSleepSession(it) - } + transactor.run { writeSleepSessions(sessions) } + } + + private suspend fun writeSleepSessions(sessions: List, rejectIncompleteReplacement: Boolean = false) { + for (candidate in sessions) { + val existing = dao.sleepSession(candidate.deviceId, candidate.startTs) + if (existing == null) { + dao.insertSleepSession(candidate) + } else { + val merged = SleepSessionUpsertPolicy.merge(existing, candidate) + check(!rejectIncompleteReplacement || merged != null) { + "Available inputs produced a less complete night." } + if (merged != null) dao.updateSleepSession(merged) } } } @@ -786,6 +791,7 @@ class WhoopRepository( suspend fun deleteComputedDailyInRange(deviceId: String, from: String, to: String) = dao.deleteDailyMetricsInRange(deviceId, from, to) + /** Swift twin: `WhoopStore.persistComputedScores`. */ suspend fun replaceComputedScoreWindow( deviceId: String, from: String, @@ -795,10 +801,46 @@ class WhoopRepository( provenance: List, replaceMetricKeys: List = emptyList(), replaceMetricSourceIds: List = listOf(deviceId), - ) = dao.replaceComputedScoreWindow( - deviceId, from, to, dailyMetrics, metricPoints, provenance, - replaceMetricKeys, replaceMetricSourceIds, - ) + sleepSessions: List? = null, + dependentRecoveryUpdates: List = emptyList(), + sleepMotion: Map> = emptyMap(), sleepStates: Map> = emptyMap(), + selectedSleepBounds: LongRange? = null, + ) { + if (dailyMetrics.isEmpty()) return + if (sleepSessions == null) { + dao.replaceComputedScoreWindow(deviceId, from, to, dailyMetrics, metricPoints, provenance, + replaceMetricKeys, replaceMetricSourceIds) + return + } + if (selectedSleepBounds != null) require(dailyMetrics.all { it.day in from..to }) + val scopedPoints = if (selectedSleepBounds == null) metricPoints else metricPoints.filter { it.day in from..to } + val scopedProvenance = if (selectedSleepBounds == null) provenance else provenance.filter { it.day in from..to } + transactor.run { + kotlinx.coroutines.currentCoroutineContext().ensureActive() + writeSleepSessions(sleepSessions, rejectIncompleteReplacement = true) + dao.replaceComputedScoreWindow(deviceId, from, to, dailyMetrics, scopedPoints, scopedProvenance, + replaceMetricKeys, replaceMetricSourceIds) + for (update in dependentRecoveryUpdates) { + dao.updateSleepDependentRecovery(deviceId, update.day, update.recovery, update.skinTempDevC) + if (update.recovery == null) dao.deleteRecoveryProvenance(deviceId, update.day) + else update.sourceId?.let { source -> + dao.upsertScoreInputProvenance(listOf(ScoreInputProvenanceRow(deviceId, update.day, "recovery", source))) + } + } + val freshStarts = sleepSessions.map { it.startTs }.toSet() + for ((start, values) in sleepMotion) if (start in freshStarts) persistSessionMotion(deviceId, start, values) + for ((start, values) in sleepStates) if (start in freshStarts) persistSessionSleepState(deviceId, start, values) + if (selectedSleepBounds != null && sleepSessions.isNotEmpty()) { + require(deviceId.endsWith("-noop")) + val stored = dao.sleepSessions(deviceId, selectedSleepBounds.first - 30 * 3600, + selectedSleepBounds.last, 4000).filter { it.endTs in selectedSleepBounds } + for (stale in com.noop.analytics.SleepSessionDedup.dedupe(stored, freshStarts).dropped) { + dao.deleteSleepSession(deviceId, stale.startTs) + } + } + kotlinx.coroutines.currentCoroutineContext().ensureActive() + } + } /** Computed-only daily rows; unlike daysMerged this can never substitute imported/calendar steps. */ suspend fun computedDailyUnion(activeStrapId: String, from: String, to: String): List = diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index c76e2c8ed9..34c4632ac1 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1944,6 +1944,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { rescoreAfterEdit() } + private val selectedSleepRecalculation = SelectedSleepRecalculation() + + // The selected scan retains deletion markers, imported rows, and manual edits. + suspend fun recalculateSleepNight(day: String): SleepRecalculationResult { + val window = com.noop.analytics.SleepRecalculationWindow.make( + day, java.time.ZoneId.systemDefault(), System.currentTimeMillis() / 1000L, + ) + return selectedSleepRecalculation.run(window, hasDeletedWindow = { selected -> + repository.dismissedSleepsUnion(activeStrapId).any { + it.startTs < selected.end && it.endTs >= selected.start + } + }) { scoreSleepWindow(it) } + } + /** Re-open one deliberately deleted sleep window (#515): remove its durable tombstone first, then * immediately run the normal detector/scorer over the stored raw data. Unlike optimistic edits, this * returns false when the marker could not be removed — rescoring while it is still present would keep @@ -2014,13 +2028,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * is rethrown so a ViewModel teardown mid-edit isn't swallowed (matches the loop's #125 handling). */ private suspend fun rescoreAfterEdit() { - runCatching { - // #1816: set the motion sink before the pass, clear it after (same pattern as the 15-min loop). - IntelligenceEngine.stepsHasMotionSink = { hasMotion -> - profileStore.stepsHasBankedMotion = hasMotion - } - IntelligenceEngine.analyzeRecent( + runCatching { scoreSleepWindow(null) } + .onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } + } + + private suspend fun scoreSleepWindow( + selectedNight: com.noop.analytics.SleepRecalculationWindow?, + ): List { + if (selectedNight == null) IntelligenceEngine.stepsHasMotionSink = { hasMotion -> profileStore.stepsHasBankedMotion = hasMotion } + try { + return IntelligenceEngine.analyzeRecent( repo = repository, + maxDays = if (selectedNight == null) 21 else 1, + nowSeconds = selectedNight?.let { it.end - 1 } ?: (System.currentTimeMillis() / 1000L), + selectedNight = selectedNight, profile = currentProfile(), importedDeviceId = deviceId, maxHROverride = profileStore.hrMaxOverride @@ -2057,8 +2078,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { effortMethod = NoopPrefs.effortMethod(appContext), dayCycleMode = NoopPrefs.dayCycleMode(appContext), ) - }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } - IntelligenceEngine.stepsHasMotionSink = null + } finally { + if (selectedNight == null) IntelligenceEngine.stepsHasMotionSink = null + } } fun loadWorkouts() { viewModelScope.launch { diff --git a/android/app/src/main/java/com/noop/ui/SelectedSleepRecalculation.kt b/android/app/src/main/java/com/noop/ui/SelectedSleepRecalculation.kt new file mode 100644 index 0000000000..985b164849 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/SelectedSleepRecalculation.kt @@ -0,0 +1,37 @@ +package com.noop.ui + +import com.noop.analytics.IntelligenceEngine +import com.noop.analytics.SleepRecalculationWindow +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex + +enum class SleepRecalculationResult { + COMPLETED, NO_SLEEP_DETECTED, INSUFFICIENT_DATA, INVALID_DATE, BUSY, DELETION_SUPPRESSED, FAILED, +} + +/** Serializes the explicit action and reports only the result of the selected production scan. */ +class SelectedSleepRecalculation { + private val gate = Mutex() + + suspend fun run( + window: SleepRecalculationWindow?, + hasDeletedWindow: suspend (SleepRecalculationWindow) -> Boolean = { false }, + scan: suspend (SleepRecalculationWindow) -> List, + ): SleepRecalculationResult { + if (window == null) return SleepRecalculationResult.INVALID_DATE + if (!gate.tryLock()) return SleepRecalculationResult.BUSY + try { + if (hasDeletedWindow(window)) return SleepRecalculationResult.DELETION_SUPPRESSED + val selected = scan(window).firstOrNull { it.day == window.day } + ?: return SleepRecalculationResult.INSUFFICIENT_DATA + return if ((selected.sleepMin ?: 0.0) > 0.0) SleepRecalculationResult.COMPLETED + else SleepRecalculationResult.NO_SLEEP_DETECTED + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return SleepRecalculationResult.FAILED + } finally { + gate.unlock() + } + } +} diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index 5dbc199f6e..dcff82ed9b 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -236,6 +236,8 @@ fun SleepScreen( // mergeSleep but WITHOUT the per-night collapse). Keyed on `days` so a sync/import (which always // rewrites dailyMetric too) reloads; these reads have no Flow. (#160, #170) var sleeps by remember { mutableStateOf>(emptyList()) } + var selectedSleepReload by remember { mutableIntStateOf(0) } + var requestedSleepDay by remember { mutableStateOf(null) } var loadedSleepDays by remember { mutableStateOf?>(null) } var loadedSleepStrap by remember { mutableStateOf(null) } val sleepRowsReady = loadedSleepDays == days && loadedSleepStrap == vm.activeStrapId @@ -248,7 +250,7 @@ fun SleepScreen( // `sleeps` in place WITHOUT touching `days`, so it must not reset the browse — keeping the // user on the night they just edited. (#160) var nightOffset by remember { mutableIntStateOf(0) } - LaunchedEffect(days, vm.activeStrapId) { + LaunchedEffect(days, vm.activeStrapId, selectedSleepReload) { loadedSleepDays = null val strap = vm.activeStrapId val loaded = runCatching { @@ -512,6 +514,15 @@ fun SleepScreen( .toSortedMap(reverseOrder()) // newest day first .map { (_, blocks) -> blocks.sortedBy { it.effectiveStartTs } } } + LaunchedEffect(sleeps, loadedSleepDays, requestedSleepDay) { + val selected = requestedSleepDay + if (selected != null && loadedSleepDays != null) { + val index = navDays.indexOfFirst { group -> group.any { localDayString(it.endTs) == selected } } + if (index >= 0) nightOffset = index + requestedSleepDay = null + } + } + // The navigated night, decoded once per (offset, data) change — chevron taps re-pick // instantly without re-parsing stagesJSON on every recomposition. The offset now indexes @@ -615,6 +626,10 @@ fun SleepScreen( // (Today / metric-detail parity — the same two prefs drive the same two behaviours everywhere). fullBleedBackground = screenBackdropFullBleed(showDayCycleBackground, skyBehindCards), ) { + item { SelectedNightRecalculationCard(vm) { day -> + requestedSleepDay = day + selectedSleepReload++ + } } // #65: the transient UNDO banner after a suppressing delete. Restores the deleted row into its // ORIGINAL namespace + lifts the tombstone. Mirrors the macOS SleepView sleepUndoBanner. sleepUndo?.let { undo -> @@ -1005,6 +1020,62 @@ fun SleepScreen( } } +/** Explicit completed-date action, available even when no sleep session exists. */ +@Composable +private fun SelectedNightRecalculationCard(vm: AppViewModel, onRecalculated: (String) -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + val scope = androidx.compose.runtime.rememberCoroutineScope() + var day by remember { mutableStateOf(LocalDate.now().minusDays(1)) } + var running by remember { mutableStateOf(false) } + var outcome by remember { mutableStateOf(null) } + NoopCard(tint = Palette.restColor) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space12)) { + Text(stringResource(R.string.sleep_recalculate_title), style = NoopType.headline) + Text(stringResource(R.string.sleep_recalculate_description), style = NoopType.footnote, + color = Palette.textSecondary) + TextButton(enabled = !running, onClick = { + android.app.DatePickerDialog(context, { _, year, month, date -> + day = LocalDate.of(year, month + 1, date) + outcome = null + }, day.year, day.monthValue - 1, day.dayOfMonth).apply { + datePicker.maxDate = LocalDate.now().atStartOfDay(java.time.ZoneId.systemDefault()) + .toInstant().toEpochMilli() - 1 + }.show() + }) { Text(stringResource(R.string.sleep_recalculate_wake_date, day.toString())) } + Button(enabled = !running, onClick = { + if (!running) { + val selected = day.toString() + running = true + outcome = null + scope.launch { + try { + val result = vm.recalculateSleepNight(selected) + outcome = result + if (result == SleepRecalculationResult.COMPLETED || result == SleepRecalculationResult.NO_SLEEP_DETECTED) { + onRecalculated(selected) + } + } + finally { running = false } + } + } + }) { Text(stringResource(R.string.sleep_recalculate_title)) } + if (running) androidx.compose.material3.CircularProgressIndicator(color = Palette.restColor) + outcome?.let { result -> + val message = when (result) { + SleepRecalculationResult.COMPLETED -> R.string.sleep_recalculate_completed + SleepRecalculationResult.NO_SLEEP_DETECTED -> R.string.sleep_recalculate_no_sleep + SleepRecalculationResult.INSUFFICIENT_DATA -> R.string.sleep_recalculate_no_data + SleepRecalculationResult.INVALID_DATE -> R.string.sleep_recalculate_invalid_date + SleepRecalculationResult.BUSY -> R.string.sleep_recalculate_busy + SleepRecalculationResult.DELETION_SUPPRESSED -> R.string.sleep_recalculate_deleted + SleepRecalculationResult.FAILED -> R.string.sleep_recalculate_failed + } + Text(stringResource(message), style = NoopType.footnote, color = Palette.textSecondary) + } + } + } +} + /** The existing alarm settings, reachable from Sleep with or without recorded nights. */ @Composable private fun SleepAlarmsEntry(onOpenAlarms: () -> Unit) { diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8399bc377c..94f1c16626 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2873,4 +2873,14 @@ Auf diesem Strap läuft möglicherweise noch eine Aufzeichnung. Das Ergebnis steht im Strap-Log. Erscheint heute, wenn Live Sessions in den Einstellungen aktiviert sind oder eine Session aktiv ist. Today nach Ihren Wünschen, Herzfrequenz für jede App und Italienisch + Schlaf neu berechnen + Wähle das Datum, an dem du aufgewacht bist. Nutzt nur bereits auf diesem Gerät vorhandene Daten. Importe, manuelle Änderungen und gelöschte Zeiträume bleiben erhalten. + Aufwachdatum: %1$s + Der Schlaf des gewählten Tages wurde aus den verfügbaren Daten neu berechnet. + In den verfügbaren Daten wurde kein Schlaf erkannt. Gespeicherte Änderungen und gelöschte Zeiträume blieben erhalten. + Für diesen Tag fehlen nutzbare Daten. Synchronisiere das Band und versuche es erneut. Die Neuberechnung ruft keine Daten ab. + Wähle ein Aufwachdatum eines abgeschlossenen vergangenen Tages. + Eine andere Neuberechnung läuft. Versuche es danach erneut. + Dieser Tag konnte nicht neu berechnet werden. Versuche es erneut. + Dieser Tag enthält gelöschten Schlaf. Die Löschung blieb erhalten; es wurde keine Neuberechnung ausgeführt. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 20aed28131..e3278d3b27 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2860,4 +2860,14 @@ Puede que siga activa una captura en esta correa. El veredicto se registra en el strap log. Aparece hoy si Live Sessions está activado en Ajustes o hay una sesión activa. Hoy a tu manera, frecuencia cardíaca para cualquier aplicación e italiano + Recalcular el sueño + Selecciona la fecha en que despertaste. Solo usa datos que ya están en este dispositivo. Se conservan las importaciones, las ediciones manuales y los intervalos eliminados. + Fecha al despertar: %1$s + Se recalculó el sueño del día seleccionado con los datos disponibles. + No se detectó sueño en los datos disponibles. Se conservaron las ediciones guardadas y los intervalos eliminados. + No hay suficientes datos utilizables para este día. Sincroniza la pulsera y vuelve a intentarlo. El recálculo no descarga datos. + Elige una fecha de despertar de un día pasado ya finalizado. + Hay otro recálculo en curso. Vuelve a intentarlo cuando termine. + No se pudo recalcular este día. Vuelve a intentarlo. + Este día incluye sueño eliminado. Se conservó la eliminación; no se realizó ningún recálculo. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bd1c4cc3b1..78e4d328fd 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2859,4 +2859,14 @@ Une capture est peut-être encore active sur ce bracelet. Le verdict est écrit dans le strap log. Apparaît aujourd’hui si les sessions en direct sont activées dans Paramètres ou si une session est active. Aujourd\'hui à votre façon, fréquence cardiaque lisible par toute application, et l\'italien + Recalculer le sommeil + Sélectionnez la date de votre réveil. Utilise uniquement les données déjà présentes sur cet appareil. Les importations, modifications manuelles et périodes supprimées sont conservées. + Date du réveil: %1$s + Le sommeil du jour sélectionné a été recalculé à partir des données disponibles. + Aucun sommeil détecté dans les données disponibles. Les modifications enregistrées et périodes supprimées ont été conservées. + Les données exploitables sont insuffisantes pour ce jour. Synchronisez le bracelet, puis réessayez. Le recalcul ne récupère aucune donnée. + Choisissez une date de réveil d’un jour passé terminé. + Un autre recalcul est en cours. Réessayez lorsqu’il sera terminé. + Impossible de recalculer ce jour. Réessayez. + Ce jour comprend du sommeil supprimé. La suppression a été conservée ; aucun recalcul n’a été effectué. diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 1a47561f99..070940391a 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2828,4 +2828,14 @@ Una acquisizione potrebbe essere ancora attiva su questo strap. Il verdetto va nello strap log. Compare oggi se le sessioni in diretta sono attivate nelle Impostazioni o una sessione è attiva. Oggi a modo tuo, frequenza cardiaca leggibile da ogni app e l\'italiano + Ricalcola il sonno + Seleziona la data in cui ti sei svegliato. Usa solo dati già presenti su questo dispositivo. Importazioni, modifiche manuali e intervalli eliminati vengono mantenuti. + Data del risveglio: %1$s + Il sonno del giorno selezionato è stato ricalcolato dai dati disponibili. + Nessun sonno rilevato nei dati disponibili. Le modifiche salvate e gli intervalli eliminati sono stati mantenuti. + Dati utilizzabili insufficienti per questo giorno. Sincronizza il cinturino e riprova. Il ricalcolo non recupera nuovi dati. + Scegli una data di risveglio di un giorno passato già concluso. + È in corso un altro ricalcolo. Riprova quando sarà terminato. + Impossibile ricalcolare questo giorno. Riprova. + Questo giorno include sonno eliminato. L’eliminazione è stata mantenuta; non è stato eseguito alcun ricalcolo. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index b5e9c52de8..4ca14ccc47 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2876,4 +2876,14 @@ Na tym pasku może nadal trwać zapis. Werdykt trafia do strap log. Pojawia się dzisiaj, gdy sesje na żywo są włączone w Ustawieniach lub trwa sesja. Dziś po swojemu, tętno dostępne dla każdej aplikacji i język włoski + Przelicz sen + Wybierz datę przebudzenia. Wykorzystuje tylko dane już zapisane na tym urządzeniu. Importy, ręczne zmiany i usunięte przedziały pozostają zachowane. + Data przebudzenia: %1$s + Sen z wybranego dnia przeliczono na podstawie dostępnych danych. + Nie wykryto snu w dostępnych danych. Zachowano zapisane zmiany i usunięte przedziały. + Brakuje wystarczających danych dla tego dnia. Zsynchronizuj opaskę i spróbuj ponownie. Przeliczanie nie pobiera danych. + Wybierz datę przebudzenia z zakończonego dnia w przeszłości. + Trwa inne przeliczanie. Spróbuj ponownie po jego zakończeniu. + Nie udało się przeliczyć tego dnia. Spróbuj ponownie. + Ten dzień zawiera usunięty sen. Usunięcie zachowano; nie wykonano przeliczania. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index b64877aeb3..ec56bee609 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2852,4 +2852,14 @@ Pode ainda estar a decorrer uma captura nesta pulseira. O veredicto vai para o strap log. Aparece hoje quando as sessões em direto estão ativadas nas Definições ou existe uma sessão ativa. Hoje à sua maneira, frequência cardíaca para qualquer aplicação e italiano + Recalcular o sono + Selecione a data em que acordou. Usa apenas dados já existentes neste dispositivo. As importações, edições manuais e períodos eliminados são preservados. + Data de despertar: %1$s + O sono do dia selecionado foi recalculado a partir dos dados disponíveis. + Não foi detetado sono nos dados disponíveis. As edições guardadas e os períodos eliminados foram preservados. + Não há dados utilizáveis suficientes para este dia. Sincronize a pulseira e tente novamente. O recálculo não obtém novos dados. + Escolha uma data de despertar de um dia passado já terminado. + Está em curso outro recálculo. Tente novamente quando terminar. + Não foi possível recalcular este dia. Tente novamente. + Este dia inclui sono eliminado. A eliminação foi preservada; não foi efetuado nenhum recálculo. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 6fbec7268f..0622346113 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2755,4 +2755,14 @@ На этом ремешке запись может всё ещё идти. Результат попадёт в strap log. Появляется сегодня, если сессии в реальном времени включены в Настройках или сеанс уже активен. «Сегодня» на ваш вкус, пульс для любого приложения и итальянский язык + Пересчитать сон + Выберите дату пробуждения. Используются только данные, уже сохранённые на этом устройстве. Импорт, ручные правки и удалённые интервалы сохраняются. + Дата пробуждения: %1$s + Сон за выбранный день пересчитан по доступным данным. + В доступных данных сон не обнаружен. Сохранённые правки и удалённые интервалы сохранены. + За этот день недостаточно пригодных данных. Синхронизируйте браслет и повторите попытку. Пересчёт не загружает данные. + Выберите дату пробуждения за завершённый день в прошлом. + Уже выполняется другой пересчёт. Повторите попытку после его завершения. + Не удалось пересчитать этот день. Повторите попытку. + За этот день есть удалённый сон. Удаление сохранено; пересчёт не выполнялся. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 75a9e8a04f..d128b9b252 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2830,4 +2830,14 @@ 此腕带上可能仍在采集。结论会写入 strap log。 在设置中启用Live Sessions 实时记录或有正在进行的训练时,今天会显示此部分。 自定义「今天」页面、任何应用都能读取的心率,以及意大利语 + 重新计算睡眠 + 选择你醒来的日期。仅使用已保存在此设备上的数据。保留导入记录、手动编辑和已删除的时段。 + 醒来日期: %1$s + 已根据可用数据重新计算所选日期的睡眠。 + 未在可用数据中检测到睡眠。已保留保存的编辑和已删除的时段。 + 此日期没有足够的可用数据。请同步腕带后重试。重新计算不会获取新数据。 + 请选择已结束的过去日期作为醒来日期。 + 另一次重新计算正在进行。请在完成后重试。 + 无法重新计算此日期。请重试。 + 此日期包含已删除的睡眠。删除状态已保留,未进行重新计算。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5ae979343a..ed776eb2a5 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2889,4 +2889,14 @@ A capture may still be running on this strap. The verdict goes to the strap log. Appears today when Live Sessions is enabled in Settings or a session is active. Today your way, heart rate any app can read, and Italian + Recalculate sleep + Select the date you woke up. Uses only data already on this device. Imports, manual edits and deleted windows are preserved. + Wake date: %1$s + Selected day\'s sleep recalculated from available data. + No sleep detected from available data. Saved edits and deleted windows were preserved. + Not enough usable data for this day. Sync the strap, then try again. Recalculation does not fetch data. + Choose a completed past wake date. + Another recalculation is running. Try again when it finishes. + Couldn\'t recalculate this day. Try again. + This day includes deleted sleep. Its deletion was preserved; no recalculation was run. diff --git a/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt b/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt index f8d5cc11a6..0c1108abde 100644 --- a/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt +++ b/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt @@ -69,10 +69,21 @@ class IntelligenceEngineJacocoBudgetTest { val calls = nameUses.filter { it != nameOffsetInDeclaration } assertEquals("Expected exactly one call to $helperName", 1, calls.size) - val replace = requireExactlyOne(code, Regex("""\bIntelligencePersistence\s*\.\s*persistComputedWindow\s*\(""")) - val dismissed = requireExactlyOne(code, Regex("""\bDismissedSleepGuard\s*\.\s*keeping\s*\(""")) - assertTrue("$helperName must run after replaceComputedScoreWindow", calls.single() > replace) - assertTrue("$helperName must run before DismissedSleepGuard.keeping", calls.single() < dismissed) + val cpuStart = code.indexOf("private suspend fun analyzeRecentOnCpu") + val signatureStart = code.indexOf('(', cpuStart) + var signatureEnd = signatureStart + 1 + var parentheses = 1 + while (parentheses > 0) { + when (code[signatureEnd++]) { '(' -> parentheses++; ')' -> parentheses-- } + } + val cpuOpen = code.indexOf('{', signatureEnd) + val cpu = code.substring(cpuOpen, matchingBrace(code, cpuOpen) + 1) + // Selected filtering precedes its atomic commit; normal filtering stays after weekly work. + val replace = cpuOpen + requireExactlyOne(cpu, Regex("""\bpersistSelectedOrRecentWindow\s*\(""")) + val dismissed = cpuOpen + requireExactlyOne(cpu, Regex("""\bDismissedSleepGuard\s*\.\s*keeping\s*\(""")) + requireExactlyOne(code, Regex("""\bIntelligencePersistence\s*\.\s*persistComputedWindow\s*\(""")) + assertTrue("$helperName must run after the transaction funnel", calls.single() > replace) + assertTrue("$helperName must run before normal DismissedSleepGuard.keeping", calls.single() < dismissed) val bodyOpen = code.indexOf('{', declaration.single().range.last + 1) assertTrue("Missing body for $helperName", bodyOpen >= 0) diff --git a/android/app/src/test/java/com/noop/analytics/SleepRecalculationDependentsTest.kt b/android/app/src/test/java/com/noop/analytics/SleepRecalculationDependentsTest.kt new file mode 100644 index 0000000000..7549050a76 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepRecalculationDependentsTest.kt @@ -0,0 +1,44 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric +import org.junit.Assert.* +import org.junit.Test + +class SleepRecalculationDependentsTest { + private fun rows(hrv: Double) = (0 until 31).map { i -> + DailyMetric("my-whoop-noop", "2026-07-%02d".format(java.util.Locale.ROOT, i + 1), + totalSleepMin = 420.0, efficiency = 0.9, deepMin = 90.0, remMin = 100.0, lightMin = 230.0, + disturbances = 1, restingHr = 48 + i % 5, avgHrv = if (i == 19) hrv else (45 + (i % 7) * 3).toDouble(), + recovery = -1.0, strain = 10.0, exerciseCount = 0, respRateBpm = 15.0 + (i % 4) * 0.2, skinTempC = 33.0 + (i % 3) * 0.1) + } + private fun updates(hrv: Double, refresh: Boolean, selected: String = "2026-07-20") = + SleepRecalculationDependents.updates(selected, "2026-07-31", refresh, emptyList(), rows(hrv), emptyMap(), "my-whoop", 0.0, 0.0) + + @Test fun onlyCurrentBaselineWindowCanChange() { + assertEquals((11..31).map { "2026-07-%02d".format(java.util.Locale.ROOT, it) }, updates(80.0, true).map { it.day }) + assertTrue(updates(80.0, true, "2026-07-01").isEmpty()) + assertTrue(updates(80.0, true, "2026-08-01").isEmpty()) + assertEquals(listOf("2026-07-20"), updates(80.0, false).map { it.day }) + } + + @Test fun scoresMatchProductionSwiftOracle() { + val actual = listOf(20.0, 40.0, 80.0, 120.0).flatMap { hrv -> + listOf(false, true).map { refresh -> + val values = updates(hrv, refresh).joinToString(";") { "${it.day}:${it.recovery ?: "nil"}:${it.skinTempDevC ?: "nil"}" } + "${hrv.toInt()}|$refresh|$values" + } + }.joinToString("\n") + // Verbatim production Swift helper output, also pinned in the Swift regression. + val expected = """ + 20|false|2026-07-20:1.0898008256514333:-0.01 + 20|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:1.0898008256514333:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11 + 40|false|2026-07-20:15.33364258561113:-0.01 + 40|true|2026-07-11:66.37246191295942:-0.01;2026-07-12:71.44762686950502:0.09;2026-07-13:77.92785705946825:-0.11;2026-07-14:81.95282906131843:-0.01;2026-07-15:26.347259541600412:0.09;2026-07-16:46.32062075209378:-0.11;2026-07-17:55.141780234075696:-0.01;2026-07-18:60.913758871911384:0.09;2026-07-19:66.50266795966225:-0.11;2026-07-20:15.33364258561113:-0.01;2026-07-21:87.16217085712255:0.09;2026-07-22:34.955910548564134:-0.11;2026-07-23:40.87125797089343:-0.01;2026-07-24:46.70472487954506:0.09;2026-07-25:55.28617637957595:-0.11;2026-07-26:75.07086969378537:-0.01;2026-07-27:79.2437855008177:0.09;2026-07-28:82.94551899534244:-0.11;2026-07-29:30.093990149917868:-0.01;2026-07-30:35.307761470393714:0.09;2026-07-31:56.83241462753154:-0.11 + 80|false|2026-07-20:97.38816204857353:-0.01 + 80|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:97.38816204857353:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11 + 120|false|2026-07-20:99.98810350209193:-0.01 + 120|true|2026-07-11:65.32420131230487:-0.01;2026-07-12:70.6828797948411:0.09;2026-07-13:77.44651062186608:-0.11;2026-07-14:81.67989610449119:-0.01;2026-07-15:24.91999580514455:0.09;2026-07-16:44.69739202612113:-0.11;2026-07-17:53.7519605534091:-0.01;2026-07-18:59.79840270808479:0.09;2026-07-19:65.66911582956358:-0.11;2026-07-20:99.98810350209193:-0.01;2026-07-21:86.95548049684118:0.09;2026-07-22:33.272977419763485:-0.11;2026-07-23:39.299186624263605:-0.01;2026-07-24:45.31275048416332:0.09;2026-07-25:54.13115718435345:-0.11;2026-07-26:74.36835296651162:-0.01;2026-07-27:78.78332343853279:0.09;2026-07-28:82.6844267943711:-0.11;2026-07-29:28.54241577630786:-0.01;2026-07-30:33.82725931938255:0.09;2026-07-31:55.452658777165766:-0.11 + """.trimIndent() + assertEquals(expected, actual) + } +} diff --git a/android/app/src/test/java/com/noop/analytics/SleepRecalculationWindowTest.kt b/android/app/src/test/java/com/noop/analytics/SleepRecalculationWindowTest.kt new file mode 100644 index 0000000000..4750977908 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepRecalculationWindowTest.kt @@ -0,0 +1,76 @@ +package com.noop.analytics + +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SleepRecalculationWindowTest { + @Test fun savedManualWindowWinsOverDetectedOnsetWithAnotherKey() { + val window = SleepRecalculationWindow.make("2021-06-15", ZoneId.of("UTC"), 1_800_000_000)!! + val start = window.start - 3600 + val end = window.start + 6 * 3600 + val bed = window.start + 3600 + val wake = bed + 7200 + val manual = com.noop.data.SleepSession("fixture-noop", start - 30, wake, + efficiency = 1.0, restingHr = 50, avgHrv = 10.0, + stagesJSON = "[{\"start\":$bed,\"end\":$wake,\"stage\":\"deep\"}]", + userEdited = true, startTsAdjusted = bed) + val result = AnalyticsEngine.analyzeDay(window.day, + hr = (start until end).map { com.noop.data.HrSample("fixture", it, 50) }, + gravity = (start until end).map { com.noop.data.GravitySample("fixture", it, 0.0, 0.0, 1.0) }, + profile = UserProfile(), sleepDayWindow = window, preservedSleep = listOf(manual)) + assertEquals(120.0, result.daily.totalSleepMin!!, 0.0) + assertEquals(1, result.sleepSessions.size) + assertEquals(bed, result.sleepSessions.first().start) + assertEquals(wake, result.sleepSessions.first().end) + } + + // Verbatim stdout from the production Swift window compiled standalone, including DST and a skipped date. + @Test fun calendarWindowsMatchSwiftOracle() { + val actual = listOf("UTC", "America/Chicago", "Asia/Kolkata", "Australia/Lord_Howe", "Pacific/Apia") + .flatMap { zone -> + listOf("2021-06-15", "2026-03-08", "2026-11-01", "2026-04-05", "2011-12-30").map { day -> + val w = SleepRecalculationWindow.make(day, ZoneId.of(zone), 1_800_000_000) + if (w == null) "$zone|$day|invalid" + else "$zone|$day|${w.start}|${w.end}|${w.offsetSeconds}|${w.readStart}" + } + }.joinToString("\n") + val expected = """ + UTC|2021-06-15|1623715200|1623801600|0|1623607200 + UTC|2026-03-08|1772928000|1773014400|0|1772820000 + UTC|2026-11-01|1793491200|1793577600|0|1793383200 + UTC|2026-04-05|1775347200|1775433600|0|1775239200 + UTC|2011-12-30|1325203200|1325289600|0|1325095200 + America/Chicago|2021-06-15|1623733200|1623819600|-18000|1623625200 + America/Chicago|2026-03-08|1772949600|1773032400|-21600|1772841600 + America/Chicago|2026-11-01|1793509200|1793599200|-18000|1793401200 + America/Chicago|2026-04-05|1775365200|1775451600|-18000|1775257200 + America/Chicago|2011-12-30|1325224800|1325311200|-21600|1325116800 + Asia/Kolkata|2021-06-15|1623695400|1623781800|19800|1623587400 + Asia/Kolkata|2026-03-08|1772908200|1772994600|19800|1772800200 + Asia/Kolkata|2026-11-01|1793471400|1793557800|19800|1793363400 + Asia/Kolkata|2026-04-05|1775327400|1775413800|19800|1775219400 + Asia/Kolkata|2011-12-30|1325183400|1325269800|19800|1325075400 + Australia/Lord_Howe|2021-06-15|1623677400|1623763800|37800|1623569400 + Australia/Lord_Howe|2026-03-08|1772888400|1772974800|39600|1772780400 + Australia/Lord_Howe|2026-11-01|1793451600|1793538000|39600|1793343600 + Australia/Lord_Howe|2026-04-05|1775307600|1775395800|39600|1775199600 + Australia/Lord_Howe|2011-12-30|1325163600|1325250000|39600|1325055600 + Pacific/Apia|2021-06-15|1623668400|1623754800|46800|1623560400 + Pacific/Apia|2026-03-08|1772881200|1772967600|46800|1772773200 + Pacific/Apia|2026-11-01|1793444400|1793530800|46800|1793336400 + Pacific/Apia|2026-04-05|1775300400|1775386800|46800|1775192400 + Pacific/Apia|2011-12-30|invalid + """.trimIndent() + assertEquals(expected, actual) + } + + @Test fun unfinishedAndInvalidDayNeverRollForward() { + for (day in listOf("2026-02-30", "2026-13-01", "2026-1-01", "not-a-day")) { + assertNull(SleepRecalculationWindow.make(day, ZoneId.of("UTC"), 1_800_000_000)) + } + val w = SleepRecalculationWindow.make("2021-06-15", ZoneId.of("UTC"), 1_800_000_000)!! + assertNull(SleepRecalculationWindow.make(w.day, ZoneId.of("UTC"), w.end - 1)) + } +} diff --git a/android/app/src/test/java/com/noop/data/SelectedSleepScoreTransactionTest.kt b/android/app/src/test/java/com/noop/data/SelectedSleepScoreTransactionTest.kt new file mode 100644 index 0000000000..57490e7249 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/SelectedSleepScoreTransactionTest.kt @@ -0,0 +1,302 @@ +package com.noop.data + +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Proxy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +class SelectedSleepScoreTransactionTest { + private val source = "my-whoop-noop" + private val day = "2021-06-15" + private fun sleep(start: Long = 1_623_711_600, end: Long = start + 25_200, + device: String = source, edited: Boolean = false) = + SleepSession(device, start, end, 0.9, 50, 60.0, + """[{"start":$start,"end":$end,"stage":"deep"}]""", userEdited = edited) + + /** DAO queries are in-memory test storage; the transaction and DAO default method are production code. */ + private class Fixture { + val sleeps = linkedMapOf, SleepSession>() + val days = linkedMapOf, DailyMetric>() + val provenance = linkedMapOf, ScoreInputProvenanceRow>() + val metrics = linkedMapOf, MetricSeriesRow>() + var failDailyWrite = false + var failDependentWrite = false + var failSleepDetails = false + var legacyRrWithheld = false + var transactions = 0 + var rawHr: List = emptyList() + var rawGravity: List = emptyList() + val rawReads = mutableListOf>() + private var depth = 0 + val dao = Proxy.newProxyInstance(WhoopDao::class.java.classLoader, arrayOf(WhoopDao::class.java)) { proxy, method, arguments -> + val args = arguments ?: emptyArray() + if (method.name.startsWith("upsert") || method.name.startsWith("delete") || method.name.startsWith("insert") || method.name.startsWith("update") || method.name == "replaceComputedScoreWindow") + check(depth > 0) { "Every selected write must share the transaction" } + when (method.name) { + "countHrInWindow" -> rawHr.count { it.deviceId == args[0] && it.ts in (args[1] as Long)..(args[2] as Long) } + "maxHrTsInWindow" -> rawHr.filter { it.deviceId == args[0] && it.ts in (args[1] as Long)..(args[2] as Long) }.maxOfOrNull { it.ts } ?: 0L + "firstStepTimestamp", "lastStepTimestamp", "stepSampleBefore" -> null + "hasStepActivityClasses" -> false + "hrSamples" -> { + val from = args[1] as Long; val to = args[2] as Long + rawReads.add(from to to) + rawHr.filter { it.deviceId == args[0] && it.ts in from..to }.take(args[3] as Int) + } + "gravitySamples" -> rawGravity.filter { it.deviceId == args[0] && it.ts in (args[1] as Long)..(args[2] as Long) }.take(args[3] as Int) + "sleepSessions" -> sleeps.values.filter { it.deviceId == args[0] && it.endTs >= args[1] as Long && it.startTs <= args[2] as Long } + "dailyMetricsRange" -> days.values.filter { it.deviceId == args[0] && it.day >= args[1] as String && it.day <= args[2] as String } + "days" -> days.values.filter { it.deviceId == args[0] } + "scoreInputSource" -> provenance[Triple(args[0] as String, args[1] as String, args[2] as String)]?.sourceId + "pairedDevice", "activePairedDevice", "activeDeviceId", "latestHrSampleTs", "sessionSleepStateJson", "sessionMotionJson" -> null + "updateSessionMotion", "updateSessionSleepState" -> { + if (failSleepDetails) error("sleep detail failure") + val key = args[0] as String to args[1] as Long + sleeps[key]?.let { row -> sleeps[key] = if (method.name == "updateSessionMotion") row.copy(motionJSON = args[2] as String?) else row.copy(sleepStateJSON = args[2] as String?) }; 1 + } + "deleteSleepSession" -> { sleeps.remove(args[0] as String to args[1] as Long); Unit } + "hasHrInWindow" -> rawHr.any { it.deviceId == args[0] && it.ts in (args[1] as Long)..(args[2] as Long) } + "hasWhoop5RrSource", "legacyWhoop5RrWithheld" -> legacyRrWithheld + "hasWhoop4HistoricalRrSource" -> false + "dayStreamFingerprint", "hrFingerprintWindow" -> "fixture" + "pairedDevices", "dayOwnerships", "appleDaily", "rrIntervals", "rawRrIntervals", "whoop5RrIntervals", "whoop4RrIntervals", "respSamples", "stepSamples", "skinTempSamples", "spo2Samples", "events", "sleepStateSamples", "v18AuxSamples", "workouts", "dismissedSleeps", "editedSleepSessions", "metricSeries", "detectedWorkouts", "stepSamplesPage" -> emptyList() + "sleepSession" -> sleeps[args[0] as String to args[1] as Long] + "insertSleepSession", "updateSleepSession" -> { + val row = args[0] as SleepSession + sleeps[row.deviceId to row.startTs] = row + if (method.name == "insertSleepSession") 1L else 1 + } + "replaceComputedScoreWindow" -> { + val implementation = Class.forName("com.noop.data.WhoopDao\$DefaultImpls") + .methods.single { it.name == method.name } + try { implementation.invoke(null, proxy, *args) } + catch (error: InvocationTargetException) { throw error.cause!! } + } + "deleteDailyMetricsInRange" -> { + days.keys.removeAll { it.first == args[0] && it.second >= args[1] as String && it.second <= args[2] as String }; Unit + } + "deleteScoreInputProvenanceInRange" -> { + provenance.keys.removeAll { it.first == args[0] && it.second >= args[1] as String && it.second <= args[2] as String && it.third != "vo2max_est" }; Unit + } + "deleteMetricSeriesKeyInRange" -> { + metrics.keys.removeAll { it.first == args[0] && it.third == args[1] && it.second >= args[2] as String && it.second <= args[3] as String }; Unit + } + "upsertMetricSeries" -> { + @Suppress("UNCHECKED_CAST") val rows = args[0] as List + rows.forEach { metrics[Triple(it.deviceId, it.day, it.key)] = it }; Unit + } + "upsertDailyMetrics" -> { + if (failDailyWrite) error("test write failure") + @Suppress("UNCHECKED_CAST") val rows = args[0] as List + rows.forEach { days[it.deviceId to it.day] = it }; Unit + } + + "updateSleepDependentRecovery" -> { + if (failDependentWrite) error("dependent failure") + val key = args[0] as String to args[1] as String + days[key]?.let { days[key] = it.copy(recovery = args[2] as Double?, skinTempDevC = args[3] as Double?) }; Unit + } + "deleteRecoveryProvenance" -> { provenance.remove(Triple(args[0] as String, args[1] as String, "recovery")); Unit } + "upsertScoreInputProvenance" -> { + @Suppress("UNCHECKED_CAST") val rows = args[0] as List + rows.forEach { provenance[Triple(it.deviceId, it.day, it.key)] = it }; Unit + } + else -> error("Unexpected DAO call: ${method.name}") + } + } as WhoopDao + val repo = WhoopRepository(dao, object : WhoopRepository.Transactor { + override suspend fun run(block: suspend () -> R): R { + check(depth == 0) + transactions++ + val beforeSleep = sleeps.toMap(); val beforeDays = days.toMap(); val beforeProvenance = provenance.toMap(); val beforeMetrics = metrics.toMap() + depth++ + try { return block() } + catch (error: Throwable) { + sleeps.clear(); sleeps.putAll(beforeSleep) + days.clear(); days.putAll(beforeDays) + provenance.clear(); provenance.putAll(beforeProvenance) + metrics.clear(); metrics.putAll(beforeMetrics) + throw error + } finally { depth-- } + } + }) + } + + private suspend fun commit(f: Fixture, sleeps: List, recovery: Double = 70.0) { + f.repo.replaceComputedScoreWindow(source, day, day, + listOf(DailyMetric(source, day, totalSleepMin = 420.0, recovery = recovery)), emptyList(), + listOf(ScoreInputProvenanceRow(source, day, "recovery", "active-strap")), sleepSessions = sleeps) + } + + @Test fun missingOldNightAndScoresBankTogetherWithoutChangingOtherRows() = runBlocking { + val f = Fixture(); val selected = sleep() + val other = listOf(sleep(start = selected.startTs - 86400), sleep(start = selected.endTs + 3600, edited = true), sleep(device = "my-whoop")) + other.forEach { f.sleeps[it.deviceId to it.startTs] = it } + commit(f, listOf(selected)) + assertEquals(1, f.transactions) + other.forEach { assertEquals(it, f.sleeps[it.deviceId to it.startTs]) } + assertEquals(selected, f.sleeps[source to selected.startTs]) + assertEquals(420.0, f.days[source to day]!!.totalSleepMin!!, 0.0) + assertEquals("active-strap", f.provenance[Triple(source, day, "recovery")]!!.sourceId) + } + + @Test fun incompleteDetectedNightRefreshesAtItsStableKey() = runBlocking { + val f = Fixture(); val complete = sleep() + f.sleeps[source to complete.startTs] = sleep(end = complete.startTs + 5400) + commit(f, listOf(complete)) + assertEquals(listOf(complete), f.sleeps.values.toList()) + } + + @Test fun dailyWriteFailureRollsBackSleepAndScores() = runBlocking { + val f = Fixture(); commit(f, emptyList(), 10.0) + val old = f.days.toMap(); val oldProvenance = f.provenance.toMap(); f.failDailyWrite = true + try { commit(f, listOf(sleep())); fail("write must fail") } catch (_: IllegalStateException) { } + assertTrue(f.sleeps.isEmpty()) + assertEquals(old, f.days) + assertEquals(oldProvenance, f.provenance) + } + + @Test fun cancellationBeforeWriteBanksNothing() = runBlocking { + val f = Fixture(); val gate = CompletableDeferred(); val started = CompletableDeferred() + val task = async { + started.complete(Unit) + try { gate.await() } catch (_: CancellationException) { } + commit(f, listOf(sleep())) + } + started.await(); task.cancel(); gate.complete(Unit) + try { task.await(); fail("cancellation must propagate") } catch (_: CancellationException) { } + assertEquals(1, f.transactions) + assertTrue(f.sleeps.isEmpty()); assertTrue(f.days.isEmpty()) + } + @Test fun dependentUpdatePreservesOtherFieldsAndRollsBackWithSelectedNight() = runBlocking { + val f = Fixture(); val next = "2021-06-16" + val neighbor = DailyMetric(source, next, totalSleepMin = 360.0, strain = 8.0, recovery = 10.0) + f.days[source to next] = neighbor + suspend fun update(recovery: Double, end: Long = sleep().endTs) { + f.repo.replaceComputedScoreWindow(source, day, day, + listOf(DailyMetric(source, day, totalSleepMin = 420.0, recovery = recovery)), emptyList(), emptyList(), + sleepSessions = listOf(sleep(end = end)), + dependentRecoveryUpdates = listOf(SleepRecoveryUpdate(next, 60.0, 0.2, "active-strap"))) + } + update(70.0) + assertEquals(neighbor.totalSleepMin, f.days[source to next]!!.totalSleepMin) + assertEquals(neighbor.strain, f.days[source to next]!!.strain) + assertEquals("active-strap", f.provenance[Triple(source, next, "recovery")]!!.sourceId) + val beforeSleep = f.sleeps.toMap(); val beforeDays = f.days.toMap(); val beforeSources = f.provenance.toMap() + f.failDependentWrite = true + try { update(90.0, sleep().endTs + 3600); fail("must fail") } catch (_: IllegalStateException) { } + assertEquals(beforeSleep, f.sleeps); assertEquals(beforeDays, f.days); assertEquals(beforeSources, f.provenance) + } + + private suspend fun scan(f: Fixture, window: com.noop.analytics.SleepRecalculationWindow = com.noop.analytics.SleepRecalculationWindow.make(day, java.time.ZoneId.of("UTC"), 1_800_000_000)!!): List { + return com.noop.analytics.IntelligenceEngine.analyzeRecent(f.repo, maxDays = 1, + nowSeconds = window.end - 1, selectedNight = window, useExperimentalSleepV2 = true) + } + + private fun rawNight(f: Fixture, window: com.noop.analytics.SleepRecalculationWindow = com.noop.analytics.SleepRecalculationWindow.make(day, java.time.ZoneId.of("UTC"), 1_800_000_000)!!) { + val from = window.start - 3600; val to = window.start + 6 * 3600 + f.rawHr = (from until to).map { HrSample("my-whoop", it, 50) } + f.rawGravity = (from until to).map { GravitySample("my-whoop", it, 0.0, 0.0, 1.0) } + } + + @Test fun realEngineRecoversOldMissingNightAndRefreshesIncompleteNight() = runBlocking { + val f = Fixture(); rawNight(f) + val otherDay = DailyMetric(source, "2021-06-14", totalSleepMin = 360.0, recovery = 20.0) + f.days[source to otherDay.day] = otherDay + val result = scan(f).single() + assertEquals(day, result.day); assertTrue(result.sleepMin!! > 120) + assertTrue(f.rawReads.any { it.first < 1_623_715_200 && it.second > 1_623_715_200 }) + val saved = f.sleeps.values.single { it.deviceId == source } + f.sleeps[source to saved.startTs] = saved.copy(endTs = saved.startTs + 5400) + scan(f) + assertEquals(saved.endTs, f.sleeps[source to saved.startTs]!!.endTs) + assertEquals(otherDay, f.days[source to otherDay.day]) + } + + @Test fun realEngineNoDataPreservesPreviouslySavedNightAndTotals() = runBlocking { + val f = Fixture(); val old = sleep() + f.sleeps[source to old.startTs] = old + val daily = DailyMetric(source, day, totalSleepMin = 420.0, recovery = 30.0) + f.days[source to day] = daily + assertTrue(scan(f).isEmpty()) + assertEquals(old, f.sleeps[source to old.startTs]); assertEquals(daily, f.days[source to day]) + } + + @Test fun realEnginePreservesManualWindowAndReportsPersistenceFailure() = runBlocking { + val f = Fixture(); rawNight(f) + val window = com.noop.analytics.SleepRecalculationWindow.make(day, java.time.ZoneId.of("UTC"), 1_800_000_000)!! + val bed = window.start + 3600; val wake = bed + 7200 + val manual = sleep(start = window.start - 3630, end = wake, edited = true).copy( + startTsAdjusted = bed, stagesJSON = "[{\"start\":$bed,\"end\":$wake,\"stage\":\"deep\"}]") + f.sleeps[source to manual.startTs] = manual + assertEquals(120.0, scan(f).single().sleepMin!!, 0.0) + assertEquals(listOf(manual), f.sleeps.values.toList()) + val other = Fixture(); rawNight(other); other.failDailyWrite = true + try { scan(other); fail("failed selected persistence must propagate") } catch (_: IllegalStateException) { } + assertTrue(other.sleeps.isEmpty()); assertTrue(other.days.isEmpty()) + } + + @Test fun realEngineNoDetectionPreservesExistingNightAndTotals() = runBlocking { + val f = Fixture(); rawNight(f) + f.rawGravity = f.rawGravity.mapIndexed { index, row -> row.copy(x = if (index % 2 == 0) 1.0 else -1.0) } + val old = sleep(); val daily = DailyMetric(source, day, totalSleepMin = 420.0, recovery = 30.0) + f.sleeps[source to old.startTs] = old; f.days[source to day] = daily + assertEquals(0.0, scan(f).single().sleepMin ?: 0.0, 0.0) + assertEquals(old, f.sleeps[source to old.startTs]); assertEquals(daily, f.days[source to day]) + } + + @Test fun selectedDetailsAndShiftedDuplicateCleanupShareTheAtomicCommit() = runBlocking { + val f = Fixture(); val fresh = sleep(); val old = sleep(start = fresh.startTs - 60, end = fresh.endTs - 3600) + f.sleeps[source to old.startTs] = old + val neighbor = sleep(start = fresh.startTs - 86400, end = fresh.endTs - 86400) + f.sleeps[source to neighbor.startTs] = neighbor + suspend fun write() = f.repo.replaceComputedScoreWindow(source, day, day, + listOf(DailyMetric(source, day, totalSleepMin = 420.0)), emptyList(), emptyList(), sleepSessions = listOf(fresh), + sleepMotion = mapOf(fresh.startTs to listOf(0.1, 0.2)), sleepStates = mapOf(fresh.startTs to listOf(1, 2)), + selectedSleepBounds = 1_623_715_200L until 1_623_801_600L) + f.failSleepDetails = true + try { write(); fail("details must fail") } catch (_: IllegalStateException) { } + assertEquals(old, f.sleeps[source to old.startTs]); assertTrue(f.days.isEmpty()) + assertFalse(f.sleeps.containsKey(source to fresh.startTs)) + f.failSleepDetails = false; write() + assertFalse(f.sleeps.containsKey(source to old.startTs)) + assertEquals(neighbor, f.sleeps[source to neighbor.startTs]) + assertEquals("[0.1,0.2]", f.sleeps[source to fresh.startTs]!!.motionJSON) + assertEquals("[1,2]", f.sleeps[source to fresh.startTs]!!.sleepStateJSON) + } + + @Test fun realEngineRetainsLegacyRrScoreAndItsActualSource() = runBlocking { + val nowSeconds = System.currentTimeMillis() / 1000L + val selectedDay = java.time.Instant.ofEpochSecond(nowSeconds).atZone(java.time.ZoneOffset.UTC).toLocalDate().minusDays(1).toString() + val window = com.noop.analytics.SleepRecalculationWindow.make(selectedDay, java.time.ZoneId.of("UTC"), nowSeconds)!! + val f = Fixture(); rawNight(f, window); f.legacyRrWithheld = true + f.days[source to selectedDay] = DailyMetric(source, selectedDay, totalSleepMin = 420.0, + avgHrv = 60.0, restingHr = 50, recovery = 44.0) + f.provenance[Triple(source, selectedDay, "recovery")] = ScoreInputProvenanceRow(source, selectedDay, "recovery", "old-rr-source") + scan(f, window) + assertEquals(44.0, f.days[source to selectedDay]!!.recovery!!, 0.0) + assertEquals(60.0, f.days[source to selectedDay]!!.avgHrv!!, 0.0) + assertEquals("old-rr-source", f.provenance[Triple(source, selectedDay, "recovery")]!!.sourceId) + } + + @Test fun selectedWriteDoesNotBankNeighboringContextMarkersOrSourceTags() = runBlocking { + val f = Fixture(); val prior = "2021-06-14" + val key = com.noop.analytics.DayCycleIntelligenceIntegration.ONSET_KEY + val neighbor = MetricSeriesRow(source, prior, key, 123.0) + f.metrics[Triple(source, prior, key)] = neighbor + val tag = ScoreInputProvenanceRow(source, prior, key, "prior-source") + f.provenance[Triple(source, prior, key)] = tag + val selected = MetricSeriesRow(source, day, key, 456.0) + f.repo.replaceComputedScoreWindow(source, day, day, + listOf(DailyMetric(source, day, totalSleepMin = 420.0)), listOf(neighbor.copy(value = 999.0), selected), + listOf(tag.copy(sourceId = "wrong-source")), replaceMetricKeys = listOf(key), sleepSessions = emptyList(), + selectedSleepBounds = 1_623_715_200L until 1_623_801_600L) + assertEquals(neighbor, f.metrics[Triple(source, prior, key)]) + assertEquals(tag, f.provenance[Triple(source, prior, key)]) + assertEquals(selected, f.metrics[Triple(source, day, key)]) + } + +} diff --git a/android/app/src/test/java/com/noop/ui/SelectedSleepRecalculationTest.kt b/android/app/src/test/java/com/noop/ui/SelectedSleepRecalculationTest.kt new file mode 100644 index 0000000000..a401578d0f --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/SelectedSleepRecalculationTest.kt @@ -0,0 +1,72 @@ +package com.noop.ui + +import com.noop.analytics.IntelligenceEngine +import com.noop.analytics.SleepRecalculationWindow +import java.time.ZoneId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SelectedSleepRecalculationTest { + private val window = SleepRecalculationWindow.make("2021-06-15", ZoneId.of("UTC"), 1_800_000_000)!! + private fun scored(day: String = window.day, sleep: Double? = 420.0) = + IntelligenceEngine.Computed(day, null, null, sleep, null, null) + + @Test fun oldSelectedDayReachesScanAndOtherDayCannotStandInForIt() = runBlocking { + val action = SelectedSleepRecalculation() + assertEquals(SleepRecalculationResult.COMPLETED, action.run(window) { + assertSame(window, it) + listOf(scored("2026-10-11"), scored()) + }) + assertEquals(SleepRecalculationResult.INSUFFICIENT_DATA, action.run(window) { + listOf(scored("2026-10-11")) + }) + } + + @Test fun noInputsAndScoredWithoutSleepHaveDifferentOutcomes() = runBlocking { + val action = SelectedSleepRecalculation() + assertEquals(SleepRecalculationResult.INSUFFICIENT_DATA, action.run(window) { emptyList() }) + assertEquals(SleepRecalculationResult.NO_SLEEP_DETECTED, action.run(window) { listOf(scored(sleep = null)) }) + assertEquals(SleepRecalculationResult.FAILED, action.run(window) { error("store read failed") }) + } + + @Test fun doubleTapCannotStartAnotherScanAndCompletionReleasesGate() = runBlocking { + val action = SelectedSleepRecalculation() + val started = CompletableDeferred() + val release = CompletableDeferred() + val first = async { + action.run(window) { started.complete(Unit); release.await(); listOf(scored()) } + } + started.await() + assertEquals(SleepRecalculationResult.BUSY, action.run(window) { error("second scan must not run") }) + release.complete(Unit) + assertEquals(SleepRecalculationResult.COMPLETED, first.await()) + assertEquals(SleepRecalculationResult.COMPLETED, action.run(window) { listOf(scored()) }) + } + + @Test fun cancellationIsPropagatedAndReleasesGate() = runBlocking { + val action = SelectedSleepRecalculation() + var cancelled = false + try { action.run(window) { throw CancellationException("view left") } } + catch (_: CancellationException) { cancelled = true } + assertTrue(cancelled) + assertEquals(SleepRecalculationResult.COMPLETED, action.run(window) { listOf(scored()) }) + } + + @Test fun intentionalDeletionStopsScanWithoutClearingIt() = runBlocking { + assertEquals(SleepRecalculationResult.DELETION_SUPPRESSED, + SelectedSleepRecalculation().run(window, hasDeletedWindow = { true }) { + error("a suppressed window must not be scored") + }) + } + + @Test fun invalidDateNeverStartsScan() = runBlocking { + assertEquals(SleepRecalculationResult.INVALID_DATE, + SelectedSleepRecalculation().run(null) { error("scan must not run") }) + } +} From 941d1223f1fd4cafd15d7c0df3276ff657c28259 Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:31:50 -0500 Subject: [PATCH 2/4] fix(sleep): use public calendar API for dependent window --- Strand/Data/IntelligenceEngine.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index f648c62f83..5fc5a51f45 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -3174,8 +3174,9 @@ final class IntelligenceEngine: ObservableObject { private func selectedNightRecoveryUpdates(store: WhoopStore, window: SleepRecalculationWindow, fresh: [DailyMetric], ownerByDay: [String: String]) async throws -> [SleepRecoveryUpdate] { let wallNow = Date() - let today = AnalyticsEngine.dayString(Int(wallNow.timeIntervalSince1970), offsetSec: TimeZone.current.secondsFromGMT(for: wallNow)) - let from = AnalyticsEngine.dayString(AnalyticsEngine.dayStartUtcSeconds(today) - 20 * 86_400) + let localDays = LocalDayWindows(timeZone: .current) + let today = localDays.dayKey(for: wallNow) + let from = localDays.localDate(of: wallNow).adding(days: -20).key guard window.day >= from else { return [] } let to = today let imported = try await store.dailyMetrics(deviceId: deviceId, from: from, to: to) From ced6accc7e757209f6ac5ee47b0347fbb229c520 Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:39:09 -0500 Subject: [PATCH 3/4] fix(sleep): retain the empty-state view builder --- Strand/Screens/SleepView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index da3d540289..d5c4929a97 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -2258,6 +2258,7 @@ struct SleepView: View { } } + @ViewBuilder private var emptyState: some View { SleepFreshnessNote(latestWakeTs: nil) if repo.loaded { From 97448b8a9ab0a275b3f27e6b2f1b1fb498793e85 Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:01:03 -0500 Subject: [PATCH 4/4] fix(sleep): reject invalid cleanup scope and reuse baselines --- .../SleepRecalculationDependents.swift | 24 +++++++------- .../ScoreInputProvenanceStore.swift | 4 ++- .../SelectedSleepScoreTransactionTests.swift | 31 +++++++++++++++++++ .../analytics/SleepRecalculationDependents.kt | 20 ++++++------ 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift index 32058ebde8..2223fe939f 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepRecalculationDependents.swift @@ -9,24 +9,24 @@ public enum SleepRecalculationDependents { hrvEpoch: Double, recoveryEpoch: Double) -> [SleepRecoveryUpdate] { guard let selected = Baselines.isoEpochDay(selectedDay), let anchor = Baselines.isoEpochDay(anchorDay), anchor >= selected, anchor - selected < ChargeBaselines.windowDays else { return [] } + let resolved = ChargeBaselines.resolve(imported: imported, own: computed, anchorDay: anchorDay, + hrvEpoch: hrvEpoch, recoveryEpoch: recoveryEpoch) + let sourceDays = Set((imported + computed).filter { $0.day <= anchorDay }.map(\.day)).sorted() + let respEpoch = max(recoveryEpoch, Baselines.deviceEraEpoch( + sourceDays.map { (day: $0, sourceId: sourceByDay[$0] ?? importedSource) })) + let respBase = Baselines.foldHistory(resolved.respHistory.values, dayKeys: resolved.respHistory.dayKeys, + cfg: Baselines.respCfg, baselineEpoch: respEpoch) + let skinCfg = Baselines.metricCfg["skin_temp"]! + let skinHistory = ChargeBaselines.history(imported: [], own: computed.map { ($0.day, $0.skinTempC) }, + anchorDay: anchorDay, cfg: skinCfg, baselineEpoch: recoveryEpoch) + let skinBase = Baselines.foldHistory(skinHistory.values, dayKeys: skinHistory.dayKeys, + cfg: skinCfg, baselineEpoch: recoveryEpoch) var updates: [SleepRecoveryUpdate] = [] for row in computed.sorted(by: { $0.day < $1.day }) { guard let day = Baselines.isoEpochDay(row.day), day <= anchor, anchor - day < ChargeBaselines.windowDays, refreshBaseline || row.day == selectedDay, let hrv = row.avgHrv, let rhr = row.restingHr else { continue } - let resolved = ChargeBaselines.resolve(imported: imported, own: computed, anchorDay: anchorDay, - hrvEpoch: hrvEpoch, recoveryEpoch: recoveryEpoch) - let sourceDays = Set((imported + computed).filter { $0.day <= anchorDay }.map(\.day)).sorted() - let respEpoch = max(recoveryEpoch, Baselines.deviceEraEpoch( - sourceDays.map { (day: $0, sourceId: sourceByDay[$0] ?? importedSource) })) - let respBase = Baselines.foldHistory(resolved.respHistory.values, dayKeys: resolved.respHistory.dayKeys, - cfg: Baselines.respCfg, baselineEpoch: respEpoch) - let skinCfg = Baselines.metricCfg["skin_temp"]! - let skinHistory = ChargeBaselines.history(imported: [], own: computed.map { ($0.day, $0.skinTempC) }, - anchorDay: anchorDay, cfg: skinCfg, baselineEpoch: recoveryEpoch) - let skinBase = Baselines.foldHistory(skinHistory.values, dayKeys: skinHistory.dayKeys, - cfg: skinCfg, baselineEpoch: recoveryEpoch) let skinDev: Double? if let mean = row.skinTempC { skinDev = skinBase.usable ? (Baselines.deviation(mean, state: skinBase).delta * 100).rounded() / 100 : nil diff --git a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift index a8de59ed1f..15caff3eb2 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/ScoreInputProvenanceStore.swift @@ -110,7 +110,9 @@ extension WhoopStore { arguments: [Self.encodeIntArray(values), deviceId, start]) } if let bounds = selectedSleepBounds, !sleepSessions.isEmpty { - precondition(deviceId.hasSuffix("-noop")) + guard deviceId.hasSuffix("-noop") else { + throw NSError(domain: "WhoopStore.selectedSleepScope", code: 2) + } let stored = try Self.readSleepSessions(deviceId: deviceId, from: bounds.lowerBound - 30 * 3600, to: bounds.upperBound, limit: 4000, in: db).filter { bounds.contains($0.endTs) } for stale in SleepSessionDedup.dedupe(stored, freshStarts: freshStarts).dropped { diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift index 002aac6837..033e309a7e 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/SelectedSleepScoreTransactionTests.swift @@ -73,6 +73,37 @@ final class SelectedSleepScoreTransactionTests: XCTestCase { XCTAssertEqual(provenance, "active-strap") } + func testSelectedCleanupRejectsImportedDeviceAndRollsBackWrites() async throws { + let store = try await WhoopStore.inMemory() + let imported = "my-whoop" + let old = sleep(1_623_711_540, 1_623_733_200) + let fresh = sleep(1_623_711_600, 1_623_736_800) + _ = try await store.upsertSleepSessions([old], deviceId: imported) + try await store.persistComputedScores(dailyMetrics: [daily(10)], + metricPoints: [.init(day: day, key: "sleep_performance", value: 20)], + provenance: [.init(day: day, key: "recovery", sourceId: "original-source")], + deviceId: imported, from: day, to: day) + do { + try await store.persistComputedScores(dailyMetrics: [daily(70)], + metricPoints: [.init(day: day, key: "sleep_performance", value: 80)], + provenance: [.init(day: day, key: "recovery", sourceId: "replacement-source")], + deviceId: imported, from: day, to: day, sleepSessions: [fresh], + selectedSleepBounds: 1_623_715_200...1_623_801_599) + XCTFail("selected cleanup must reject an imported device") + } catch { + XCTAssertEqual((error as NSError).domain, "WhoopStore.selectedSleepScope") + XCTAssertEqual((error as NSError).code, 2) + } + let rows = try await store.sleepSessions(deviceId: imported, from: 0, to: 2_000_000_000, limit: 100) + let scores = try await store.dailyMetrics(deviceId: imported, from: day, to: day) + let points = try await store.metricSeries(deviceId: imported, key: "sleep_performance", from: day, to: day) + let provenance = try await store.scoreInputSource(deviceId: imported, day: day, key: "recovery") + XCTAssertEqual(rows.map(\.startTs), [old.startTs]) + XCTAssertEqual(scores.first?.recovery, 10) + XCTAssertEqual(points.first?.value, 20) + XCTAssertEqual(provenance, "original-source") + } + func testCancelledSelectedWriteBanksNothing() async throws { let store = try await WhoopStore.inMemory() var finish: AsyncStream.Continuation! diff --git a/android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt b/android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt index 3ff35d92d9..85fe8b3057 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepRecalculationDependents.kt @@ -12,21 +12,21 @@ object SleepRecalculationDependents { val selected = Baselines.isoEpochDay(selectedDay) ?: return emptyList() val anchor = Baselines.isoEpochDay(anchorDay) ?: return emptyList() if (anchor < selected || anchor - selected >= ChargeBaselines.windowDays) return emptyList() + val resolved = ChargeBaselines.resolve(imported, computed, anchorDay, hrvEpoch, recoveryEpoch) + val sourceDays = (imported + computed).filter { it.day <= anchorDay }.map { it.day }.distinct().sorted() + val respEpoch = maxOf(recoveryEpoch, Baselines.deviceEraEpoch( + sourceDays.map { it to (sourceByDay[it] ?: importedSource) })) + val respBase = Baselines.foldHistory(resolved.respHistory.values, resolved.respHistory.dayKeys, + Baselines.respCfg, respEpoch) + val skinCfg = Baselines.metricCfg.getValue("skin_temp") + val skinHistory = ChargeBaselines.history(emptyList(), computed.map { it.day to it.skinTempC }, + anchorDay, skinCfg, recoveryEpoch) + val skinBase = Baselines.foldHistory(skinHistory.values, skinHistory.dayKeys, skinCfg, recoveryEpoch) return computed.sortedBy { it.day }.mapNotNull { row -> val day = Baselines.isoEpochDay(row.day) ?: return@mapNotNull null if (day > anchor || anchor - day >= ChargeBaselines.windowDays || (!refreshBaseline && row.day != selectedDay)) return@mapNotNull null val hrv = row.avgHrv ?: return@mapNotNull null val rhr = row.restingHr ?: return@mapNotNull null - val resolved = ChargeBaselines.resolve(imported, computed, anchorDay, hrvEpoch, recoveryEpoch) - val sourceDays = (imported + computed).filter { it.day <= anchorDay }.map { it.day }.distinct().sorted() - val respEpoch = maxOf(recoveryEpoch, Baselines.deviceEraEpoch( - sourceDays.map { it to (sourceByDay[it] ?: importedSource) })) - val respBase = Baselines.foldHistory(resolved.respHistory.values, resolved.respHistory.dayKeys, - Baselines.respCfg, respEpoch) - val skinCfg = Baselines.metricCfg.getValue("skin_temp") - val skinHistory = ChargeBaselines.history(emptyList(), computed.map { it.day to it.skinTempC }, - anchorDay, skinCfg, recoveryEpoch) - val skinBase = Baselines.foldHistory(skinHistory.values, skinHistory.dayKeys, skinCfg, recoveryEpoch) val skinDev = row.skinTempC?.let { mean -> if (!skinBase.usable) null else { val scaled = Baselines.deviation(mean, skinBase).delta * 100.0