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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Strand/Data/LiveHRBannerInputs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Combine
import Foundation

/// When NOOP's Live HR banner reads the values it shows.
///
/// WHY THIS EXISTS. The banner shows AppModel's smoothed rate (`bpm`), which AppModel keeps with sinks of its own on
/// the live heart rate and R-R. A `@Published` sink runs in willSet, before the value lands, and several sinks on one
/// publisher run in no promised order. The banner used to refresh from a sink of its own on the heart rate, so when a
/// strap's WRIST_OFF cleared the heart rate it could run before AppModel had reset its median, read the old one and
/// send no dash. A tester's banner kept the last number for two to three minutes, until iOS's stale date drew the dash:
/// the strap log shows "Strap: WRIST_OFF; live heart rate cleared" and no banner line after it (24 Sep 2026, 10:48).
///
/// So the banner is refreshed once the changes have landed: the signals are merged and debounced to the end of the
/// main queue's current turn, where the heart rate, the link and the median all read as they now are. A clear moves
/// several of them in one turn, and makes one refresh instead of one per value.
///
/// Platform-free so `StrandTests` covers it; the controller it serves is in the iOS app target.
enum LiveHRBannerInputs {

/// One signal per turn of the main queue in which any of `changes` fired, delivered after that turn.
static func settled(_ changes: [AnyPublisher<Void, Never>]) -> AnyPublisher<Void, Never> {
Publishers.MergeMany(changes)
.debounce(for: .zero, scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
53 changes: 53 additions & 0 deletions StrandTests/LiveHRBannerInputsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Combine
import XCTest
@testable import Strand

/// The Live HR banner reads what it shows once the changes have landed, never from inside one of them: a reader called
/// from a `@Published` sink runs in willSet and still sees the old value. On a tester's phone that left the last number
/// on the banner for minutes after the strap sent WRIST_OFF (24 Sep 2026).
final class LiveHRBannerInputsTests: XCTestCase {

private func bannerSignal<P: Publisher>(_ publisher: P) -> AnyPublisher<Void, Never> where P.Failure == Never {
publisher.map { _ in () }.eraseToAnyPublisher()
}

/// WRIST_OFF clears the heart rate. The banner is refreshed after that has landed, and reads nothing inside it.
@MainActor
func testTheBannerReadsAClearedHeartRateOnlyOnceTheClearHasLanded() {
let live = LiveState()
live.heartRate = 91
var seen: [Int?] = []
let refreshed = expectation(description: "refreshed")
refreshed.assertForOverFulfill = false
let subscription = LiveHRBannerInputs.settled([bannerSignal(live.$heartRate)])
.sink { seen.append(live.heartRate); refreshed.fulfill() }
live.clearLiveHeartRate()
XCTAssertEqual(seen, [], "read inside the change, it would still say 91")
wait(for: [refreshed], timeout: 1)
XCTAssertEqual(seen, [nil])
subscription.cancel()
}

/// A clear moves the R-R and the heart rate, and a drop the link, in one turn: one refresh, after all of them.
@MainActor
func testSeveralChangesInOneTurnMakeOneRefresh() {
let live = LiveState()
live.heartRate = 91
live.rr = [650]
live.connected = true
var refreshes = 0
let refreshed = expectation(description: "refreshed")
refreshed.assertForOverFulfill = false
let subscription = LiveHRBannerInputs.settled([bannerSignal(live.$heartRate), bannerSignal(live.$rr),
bannerSignal(live.$connected)])
.sink { refreshes += 1; refreshed.fulfill() }
live.clearLiveHeartRate()
live.connected = false
wait(for: [refreshed], timeout: 1)
let laterTurn = expectation(description: "a later turn")
DispatchQueue.main.async { laterTurn.fulfill() }
wait(for: [laterTurn], timeout: 1)
XCTAssertEqual(refreshes, 1)
subscription.cancel()
}
}
31 changes: 10 additions & 21 deletions StrandiOS/Widgets/LiveActivityController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,12 @@ final class LiveActivityController {
func follow(_ model: AppModel, standsAside: @escaping () -> Bool) {
self.model = model
self.standsAside = standsAside
// A `@Published` sink runs in willSet: each hands on the value being written and reads the other from `live`.
// AppModel's own sinks, subscribed before these, have already folded the value into its median (`bpm`).
model.live.$heartRate
.sink { [weak self, weak model] hr in
guard let model else { return }
self?.refreshBanner(heartRate: hr, connected: model.live.connected)
}
.store(in: &cancellables)
model.live.$connected
.sink { [weak self, weak model] isConnected in
guard let model else { return }
self?.refreshBanner(heartRate: model.live.heartRate, connected: isConnected)
}
// Refreshed once a change has landed, never from inside it (`LiveHRBannerInputs`): a sink of its own on the
// heart rate read AppModel's median before AppModel had reset it, and a WRIST_OFF sent no dash.
LiveHRBannerInputs.settled([model.live.$heartRate.map { _ in () }.eraseToAnyPublisher(),
model.live.$connected.map { _ in () }.eraseToAnyPublisher(),
model.$bpm.map { _ in () }.eraseToAnyPublisher()])
.sink { [weak self] in self?.refreshBanner() }
.store(in: &cancellables)
// The switch is the one way to be rid of the banner, so it acts at once — not at the next heart-rate tick,
// which a strap off the wrist may not send for hours.
Expand All @@ -82,19 +75,15 @@ final class LiveActivityController {
refreshBanner(appActive: true)
}

private func refreshBanner(appActive: Bool? = nil) {
guard let model else { return }
refreshBanner(heartRate: model.live.heartRate, connected: model.live.connected, appActive: appActive)
}

/// #911: recovery and effort come from the SAME shared `Repository.widgetAnchor` the widget and the watch use, so
/// the banner cannot name a different day at the rollover; memoized, because this runs on every heart-rate tick
/// (re-deriving it once scanned the whole history, #1051).
private func refreshBanner(heartRate: Int?, connected: Bool, appActive: Bool? = nil) {
private func refreshBanner(appActive: Bool? = nil) {
guard let model else { return }
let connected = model.live.connected
let day = model.repo.cachedWidgetAnchor()
update(bpm: connected ? (model.bpm ?? heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) },
connected: connected, standsAside: standsAside(),
update(bpm: connected ? (model.bpm ?? model.live.heartRate) : nil,
recovery: day?.recovery.map { Int($0.rounded()) }, connected: connected, standsAside: standsAside(),
appActive: appActive ?? (UIApplication.shared.applicationState == .active),
effort: day?.strain.map { Int($0.rounded()) })
}
Expand Down
Loading