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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/HeartCoach/Shared/Engine/StressEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -912,13 +912,23 @@ public struct StressEngine: Sendable {

guard let snapshot = snapshots.first(where: {
calendar.isDate($0.date, inSameDayAs: targetDay)
}), let dailyHRV = snapshot.hrvSDNN else {
}) else {
return []
}

let preceding = snapshots.filter { $0.date < targetDay }
let fallbackDailyHRV = preceding
.sorted(by: { $0.date < $1.date })
.compactMap(\.hrvSDNN)
.last
guard let dailyHRV = snapshot.hrvSDNN
?? fallbackDailyHRV
?? computeBaseline(snapshots: preceding) else {
return []
}
// Use preceding days for baseline when available; fall back to today's
// own HRV so the Day heatmap works on day 1 (BUG-072).
// own HRV or the most recent usable HRV so the Day heatmap works even
// when the latest daily snapshot is partially missing (BUG-072).
let baseline = computeBaseline(snapshots: preceding) ?? dailyHRV

return hourlyStressEstimates(
Expand Down
53 changes: 53 additions & 0 deletions apps/HeartCoach/Shared/Services/DashboardTabRouter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// DashboardTabRouter.swift
// ThumpCore
//
// Centralizes dashboard navigation intents so layout changes do not break
// dashboard card taps or deep links.

import Foundation

public enum DashboardTabDestination: Equatable, Sendable {
case insights
case stress
case trends
case settings
}

public enum DashboardTabRouter {

public static func tabIndex(
for destination: DashboardTabDestination,
useNewTabLayout: Bool
) -> Int {
if useNewTabLayout {
switch destination {
case .insights, .stress, .trends:
return 1
case .settings:
return 2
}
}

switch destination {
case .insights:
return 1
case .stress:
return 2
case .trends:
return 3
case .settings:
return 4
}
}

public static func destination(for category: NudgeCategory) -> DashboardTabDestination {
switch category {
case .rest, .breathe, .seekGuidance:
return .stress
case .walk, .moderate, .intensity:
return .trends
case .hydrate, .sunlight, .celebrate:
return .insights
}
}
}
4 changes: 4 additions & 0 deletions apps/HeartCoach/Shared/Services/LocalStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ public final class LocalStore: ObservableObject {
private let encoder: JSONEncoder
private let decoder: JSONDecoder

/// Exposes the backing defaults to same-module extensions that persist
/// additional feature state without duplicating configuration.
var storageDefaults: UserDefaults { defaults }

// MARK: - Initialization

/// Creates a new `LocalStore` backed by the given `UserDefaults` suite.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ extension LocalStore {
// MARK: - Private Persistence

private func loadProactiveHistory() -> [String: [Date]] {
guard let data = UserDefaults.standard.data(forKey: Self.proactiveHistoryKey),
guard let data = storageDefaults.data(forKey: Self.proactiveHistoryKey),
let decoded = try? JSONDecoder().decode([String: [Date]].self, from: data) else {
return [:]
}
Expand All @@ -62,6 +62,6 @@ extension LocalStore {

private func saveProactiveHistory(_ history: [String: [Date]]) {
guard let data = try? JSONEncoder().encode(history) else { return }
UserDefaults.standard.set(data, forKey: Self.proactiveHistoryKey)
storageDefaults.set(data, forKey: Self.proactiveHistoryKey)
}
}
12 changes: 9 additions & 3 deletions apps/HeartCoach/Tests/ClickableDataFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -816,8 +816,8 @@ final class StressClickableDataFlowTests: XCTestCase {
XCTAssertTrue(vm.walkSuggestionShown)
}

/// handleSmartAction routes .restSuggestion to breathing session.
func testHandleSmartAction_restSuggestion_startsBreathing() {
/// handleSmartAction routes .restSuggestion to a reminder flow, not breathing.
func testHandleSmartAction_restSuggestion_dismissesWithoutBreathing() {
let vm = StressViewModel()
let nudge = DailyNudge(
category: .rest,
Expand All @@ -826,8 +826,14 @@ final class StressClickableDataFlowTests: XCTestCase {
durationMinutes: nil,
icon: "bed.double.fill"
)
vm.smartActions = [.restSuggestion(nudge), .standardNudge]
vm.smartAction = .restSuggestion(nudge)
vm.handleSmartAction(.restSuggestion(nudge))
XCTAssertTrue(vm.isBreathingSessionActive)
XCTAssertFalse(vm.isBreathingSessionActive)
XCTAssertFalse(vm.smartActions.contains(where: {
if case .restSuggestion = $0 { return true }
return false
}))
}

// MARK: - Day Selection in Week View
Expand Down
88 changes: 88 additions & 0 deletions apps/HeartCoach/Tests/CrossTabMessageConsistencyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// CrossTabMessageConsistencyTests.swift
// ThumpTests
//
// Regression coverage for contradictory guidance across Dashboard and Stress
// surfaces. These tests protect against "push hard" messaging when readiness
// is still low due to poor sleep or recovery debt.

import XCTest
@testable import Thump

final class CrossTabMessageConsistencyTests: XCTestCase {

func testStressGuidance_relaxedButRecoveringReadiness_avoidsPerformanceActions() {
let spec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .recovering)

XCTAssertFalse(spec.actions.contains("Workout"), "Recovering readiness should not promote hard workouts.")
XCTAssertFalse(spec.actions.contains("Focus Time"), "Recovering readiness should avoid high-cognitive push framing.")
XCTAssertTrue(spec.actions.contains("Rest"), "Recovering readiness should include recovery actions.")
XCTAssertTrue(spec.actions.contains("Take a Walk"), "Recovering readiness should include low-intensity movement.")
}

func testStressGuidance_relaxedAndReady_keepsPerformanceActions() {
let spec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .ready)

XCTAssertTrue(spec.actions.contains("Workout"), "Ready state should still allow performance-oriented actions.")
XCTAssertTrue(spec.actions.contains("Focus Time"), "Ready state should preserve focus-window coaching.")
}

func testPoorSleepDashboardAndStressGuidance_areAligned() {
let snapshot = HeartSnapshot(
date: Date(),
restingHeartRate: 62,
hrvSDNN: 56,
recoveryHR1m: 24,
sleepHours: 4.7
)
let state = makeRecoveryAdviceState(stressGuidanceLevel: .relaxed)

let dashboardText = AdvicePresenter.checkRecommendation(
for: state,
readinessScore: 42,
snapshot: snapshot
).lowercased()
let stressSpec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .recovering)

let recoverySignals = [
"skip structured training",
"easy walk",
"save harder sessions",
"keep it light",
"rest"
]

XCTAssertTrue(
recoverySignals.contains(where: { dashboardText.contains($0) }),
"Low-readiness + poor-sleep dashboard guidance should clearly bias recovery."
)
XCTAssertFalse(
dashboardText.contains("push hard") || dashboardText.contains("high-intensity"),
"Low-readiness + poor-sleep dashboard guidance should avoid hard-intensity wording."
)
XCTAssertFalse(stressSpec.actions.contains("Workout"))
XCTAssertFalse(stressSpec.actions.contains("Focus Time"))
}

private func makeRecoveryAdviceState(stressGuidanceLevel: StressGuidanceLevel?) -> AdviceState {
AdviceState(
mode: .lightRecovery,
riskBand: .elevated,
overtrainingState: .none,
sleepDeprivationFlag: true,
medicalEscalationFlag: false,
heroCategory: .caution,
heroMessageID: "hero_rough_night",
buddyMoodCategory: .resting,
focusInsightID: "insight_rough_night",
checkBadgeID: "badge_recover",
goals: [],
recoveryDriver: .lowSleep,
stressGuidanceLevel: stressGuidanceLevel,
smartActions: [],
allowedIntensity: .light,
nudgePriorities: [.rest, .walk],
positivityAnchorID: nil,
dailyActionBudget: 3
)
}
}
44 changes: 44 additions & 0 deletions apps/HeartCoach/Tests/DashboardTabRouterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// DashboardTabRouterTests.swift
// ThumpCoreTests
//
// Regression tests for dashboard-to-tab routing. Protects against
// hard-coded index drift when switching between legacy 5-tab and
// new 3-tab layouts.

import XCTest
@testable import Thump

final class DashboardTabRouterTests: XCTestCase {

func testLegacyTabMapping_usesExpectedIndices() {
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .insights, useNewTabLayout: false), 1)
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .stress, useNewTabLayout: false), 2)
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .trends, useNewTabLayout: false), 3)
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .settings, useNewTabLayout: false), 4)
}

func testNewTabMapping_collapsesToTrendsAndYou() {
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .insights, useNewTabLayout: true), 1)
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .stress, useNewTabLayout: true), 1)
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .trends, useNewTabLayout: true), 1)
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .settings, useNewTabLayout: true), 2)
}

func testCategoryRouting_restAndBreathe_goToStressIntent() {
XCTAssertEqual(DashboardTabRouter.destination(for: .rest), .stress)
XCTAssertEqual(DashboardTabRouter.destination(for: .breathe), .stress)
XCTAssertEqual(DashboardTabRouter.destination(for: .seekGuidance), .stress)
}

func testCategoryRouting_activityCategories_goToTrendsIntent() {
XCTAssertEqual(DashboardTabRouter.destination(for: .walk), .trends)
XCTAssertEqual(DashboardTabRouter.destination(for: .moderate), .trends)
XCTAssertEqual(DashboardTabRouter.destination(for: .intensity), .trends)
}

func testCategoryRouting_supportiveCategories_goToInsightsIntent() {
XCTAssertEqual(DashboardTabRouter.destination(for: .hydrate), .insights)
XCTAssertEqual(DashboardTabRouter.destination(for: .sunlight), .insights)
XCTAssertEqual(DashboardTabRouter.destination(for: .celebrate), .insights)
}
}
Loading
Loading