Skip to content

Commit 0d7ee63

Browse files
authored
Merge pull request #21 from cortexark/wip/dirty-worktree-cleanup
Clean up dirty worktree into reviewed source commits
2 parents 34b2368 + 0ad50f0 commit 0d7ee63

16 files changed

Lines changed: 476 additions & 130 deletions

apps/HeartCoach/Shared/Engine/StressEngine.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -912,13 +912,23 @@ public struct StressEngine: Sendable {
912912

913913
guard let snapshot = snapshots.first(where: {
914914
calendar.isDate($0.date, inSameDayAs: targetDay)
915-
}), let dailyHRV = snapshot.hrvSDNN else {
915+
}) else {
916916
return []
917917
}
918918

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

924934
return hourlyStressEstimates(
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// DashboardTabRouter.swift
2+
// ThumpCore
3+
//
4+
// Centralizes dashboard navigation intents so layout changes do not break
5+
// dashboard card taps or deep links.
6+
7+
import Foundation
8+
9+
public enum DashboardTabDestination: Equatable, Sendable {
10+
case insights
11+
case stress
12+
case trends
13+
case settings
14+
}
15+
16+
public enum DashboardTabRouter {
17+
18+
public static func tabIndex(
19+
for destination: DashboardTabDestination,
20+
useNewTabLayout: Bool
21+
) -> Int {
22+
if useNewTabLayout {
23+
switch destination {
24+
case .insights, .stress, .trends:
25+
return 1
26+
case .settings:
27+
return 2
28+
}
29+
}
30+
31+
switch destination {
32+
case .insights:
33+
return 1
34+
case .stress:
35+
return 2
36+
case .trends:
37+
return 3
38+
case .settings:
39+
return 4
40+
}
41+
}
42+
43+
public static func destination(for category: NudgeCategory) -> DashboardTabDestination {
44+
switch category {
45+
case .rest, .breathe, .seekGuidance:
46+
return .stress
47+
case .walk, .moderate, .intensity:
48+
return .trends
49+
case .hydrate, .sunlight, .celebrate:
50+
return .insights
51+
}
52+
}
53+
}

apps/HeartCoach/Shared/Services/LocalStore.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ public final class LocalStore: ObservableObject {
6161
private let encoder: JSONEncoder
6262
private let decoder: JSONDecoder
6363

64+
/// Exposes the backing defaults to same-module extensions that persist
65+
/// additional feature state without duplicating configuration.
66+
var storageDefaults: UserDefaults { defaults }
67+
6468
// MARK: - Initialization
6569

6670
/// Creates a new `LocalStore` backed by the given `UserDefaults` suite.

apps/HeartCoach/Shared/Services/ProactiveNotificationStore.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ extension LocalStore {
5353
// MARK: - Private Persistence
5454

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

6363
private func saveProactiveHistory(_ history: [String: [Date]]) {
6464
guard let data = try? JSONEncoder().encode(history) else { return }
65-
UserDefaults.standard.set(data, forKey: Self.proactiveHistoryKey)
65+
storageDefaults.set(data, forKey: Self.proactiveHistoryKey)
6666
}
6767
}

apps/HeartCoach/Tests/ClickableDataFlowTests.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,8 @@ final class StressClickableDataFlowTests: XCTestCase {
816816
XCTAssertTrue(vm.walkSuggestionShown)
817817
}
818818

819-
/// handleSmartAction routes .restSuggestion to breathing session.
820-
func testHandleSmartAction_restSuggestion_startsBreathing() {
819+
/// handleSmartAction routes .restSuggestion to a reminder flow, not breathing.
820+
func testHandleSmartAction_restSuggestion_dismissesWithoutBreathing() {
821821
let vm = StressViewModel()
822822
let nudge = DailyNudge(
823823
category: .rest,
@@ -826,8 +826,14 @@ final class StressClickableDataFlowTests: XCTestCase {
826826
durationMinutes: nil,
827827
icon: "bed.double.fill"
828828
)
829+
vm.smartActions = [.restSuggestion(nudge), .standardNudge]
830+
vm.smartAction = .restSuggestion(nudge)
829831
vm.handleSmartAction(.restSuggestion(nudge))
830-
XCTAssertTrue(vm.isBreathingSessionActive)
832+
XCTAssertFalse(vm.isBreathingSessionActive)
833+
XCTAssertFalse(vm.smartActions.contains(where: {
834+
if case .restSuggestion = $0 { return true }
835+
return false
836+
}))
831837
}
832838

833839
// MARK: - Day Selection in Week View
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// CrossTabMessageConsistencyTests.swift
2+
// ThumpTests
3+
//
4+
// Regression coverage for contradictory guidance across Dashboard and Stress
5+
// surfaces. These tests protect against "push hard" messaging when readiness
6+
// is still low due to poor sleep or recovery debt.
7+
8+
import XCTest
9+
@testable import Thump
10+
11+
final class CrossTabMessageConsistencyTests: XCTestCase {
12+
13+
func testStressGuidance_relaxedButRecoveringReadiness_avoidsPerformanceActions() {
14+
let spec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .recovering)
15+
16+
XCTAssertFalse(spec.actions.contains("Workout"), "Recovering readiness should not promote hard workouts.")
17+
XCTAssertFalse(spec.actions.contains("Focus Time"), "Recovering readiness should avoid high-cognitive push framing.")
18+
XCTAssertTrue(spec.actions.contains("Rest"), "Recovering readiness should include recovery actions.")
19+
XCTAssertTrue(spec.actions.contains("Take a Walk"), "Recovering readiness should include low-intensity movement.")
20+
}
21+
22+
func testStressGuidance_relaxedAndReady_keepsPerformanceActions() {
23+
let spec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .ready)
24+
25+
XCTAssertTrue(spec.actions.contains("Workout"), "Ready state should still allow performance-oriented actions.")
26+
XCTAssertTrue(spec.actions.contains("Focus Time"), "Ready state should preserve focus-window coaching.")
27+
}
28+
29+
func testPoorSleepDashboardAndStressGuidance_areAligned() {
30+
let snapshot = HeartSnapshot(
31+
date: Date(),
32+
restingHeartRate: 62,
33+
hrvSDNN: 56,
34+
recoveryHR1m: 24,
35+
sleepHours: 4.7
36+
)
37+
let state = makeRecoveryAdviceState(stressGuidanceLevel: .relaxed)
38+
39+
let dashboardText = AdvicePresenter.checkRecommendation(
40+
for: state,
41+
readinessScore: 42,
42+
snapshot: snapshot
43+
).lowercased()
44+
let stressSpec = AdvicePresenter.stressGuidance(for: .relaxed, readinessLevel: .recovering)
45+
46+
let recoverySignals = [
47+
"skip structured training",
48+
"easy walk",
49+
"save harder sessions",
50+
"keep it light",
51+
"rest"
52+
]
53+
54+
XCTAssertTrue(
55+
recoverySignals.contains(where: { dashboardText.contains($0) }),
56+
"Low-readiness + poor-sleep dashboard guidance should clearly bias recovery."
57+
)
58+
XCTAssertFalse(
59+
dashboardText.contains("push hard") || dashboardText.contains("high-intensity"),
60+
"Low-readiness + poor-sleep dashboard guidance should avoid hard-intensity wording."
61+
)
62+
XCTAssertFalse(stressSpec.actions.contains("Workout"))
63+
XCTAssertFalse(stressSpec.actions.contains("Focus Time"))
64+
}
65+
66+
private func makeRecoveryAdviceState(stressGuidanceLevel: StressGuidanceLevel?) -> AdviceState {
67+
AdviceState(
68+
mode: .lightRecovery,
69+
riskBand: .elevated,
70+
overtrainingState: .none,
71+
sleepDeprivationFlag: true,
72+
medicalEscalationFlag: false,
73+
heroCategory: .caution,
74+
heroMessageID: "hero_rough_night",
75+
buddyMoodCategory: .resting,
76+
focusInsightID: "insight_rough_night",
77+
checkBadgeID: "badge_recover",
78+
goals: [],
79+
recoveryDriver: .lowSleep,
80+
stressGuidanceLevel: stressGuidanceLevel,
81+
smartActions: [],
82+
allowedIntensity: .light,
83+
nudgePriorities: [.rest, .walk],
84+
positivityAnchorID: nil,
85+
dailyActionBudget: 3
86+
)
87+
}
88+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// DashboardTabRouterTests.swift
2+
// ThumpCoreTests
3+
//
4+
// Regression tests for dashboard-to-tab routing. Protects against
5+
// hard-coded index drift when switching between legacy 5-tab and
6+
// new 3-tab layouts.
7+
8+
import XCTest
9+
@testable import Thump
10+
11+
final class DashboardTabRouterTests: XCTestCase {
12+
13+
func testLegacyTabMapping_usesExpectedIndices() {
14+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .insights, useNewTabLayout: false), 1)
15+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .stress, useNewTabLayout: false), 2)
16+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .trends, useNewTabLayout: false), 3)
17+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .settings, useNewTabLayout: false), 4)
18+
}
19+
20+
func testNewTabMapping_collapsesToTrendsAndYou() {
21+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .insights, useNewTabLayout: true), 1)
22+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .stress, useNewTabLayout: true), 1)
23+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .trends, useNewTabLayout: true), 1)
24+
XCTAssertEqual(DashboardTabRouter.tabIndex(for: .settings, useNewTabLayout: true), 2)
25+
}
26+
27+
func testCategoryRouting_restAndBreathe_goToStressIntent() {
28+
XCTAssertEqual(DashboardTabRouter.destination(for: .rest), .stress)
29+
XCTAssertEqual(DashboardTabRouter.destination(for: .breathe), .stress)
30+
XCTAssertEqual(DashboardTabRouter.destination(for: .seekGuidance), .stress)
31+
}
32+
33+
func testCategoryRouting_activityCategories_goToTrendsIntent() {
34+
XCTAssertEqual(DashboardTabRouter.destination(for: .walk), .trends)
35+
XCTAssertEqual(DashboardTabRouter.destination(for: .moderate), .trends)
36+
XCTAssertEqual(DashboardTabRouter.destination(for: .intensity), .trends)
37+
}
38+
39+
func testCategoryRouting_supportiveCategories_goToInsightsIntent() {
40+
XCTAssertEqual(DashboardTabRouter.destination(for: .hydrate), .insights)
41+
XCTAssertEqual(DashboardTabRouter.destination(for: .sunlight), .insights)
42+
XCTAssertEqual(DashboardTabRouter.destination(for: .celebrate), .insights)
43+
}
44+
}

0 commit comments

Comments
 (0)