Skip to content

Commit 34b2368

Browse files
authored
Merge pull request #20 from cortexark/codex/designb-hero-night-fix
Stabilize dashboard hero states and startup validation
2 parents 3936a6e + f4dabf2 commit 34b2368

12 files changed

Lines changed: 340 additions & 95 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import Foundation
2+
3+
enum DashboardHeroPresentation {
4+
static func greetingPrefix(for hour: Int) -> String {
5+
switch hour {
6+
case 5..<12:
7+
return "Good morning"
8+
case 12..<17:
9+
return "Good afternoon"
10+
case 17..<21:
11+
return "Good evening"
12+
default:
13+
return "Good night"
14+
}
15+
}
16+
17+
static func mood(
18+
assessment: HeartAssessment?,
19+
readinessScore: Int?,
20+
hour: Int
21+
) -> BuddyMood {
22+
guard let assessment else {
23+
return isQuietHours(hour) ? .tired : .content
24+
}
25+
26+
let baseMood = BuddyMood.from(
27+
assessment: assessment,
28+
readinessScore: readinessScore,
29+
currentHour: hour
30+
)
31+
32+
guard isQuietHours(hour) else {
33+
return baseMood
34+
}
35+
36+
switch baseMood {
37+
case .stressed:
38+
return .stressed
39+
case .tired:
40+
return .tired
41+
default:
42+
return .tired
43+
}
44+
}
45+
46+
static func isQuietHours(_ hour: Int) -> Bool {
47+
hour >= 21 || hour < 5
48+
}
49+
}
50+
51+
enum DashboardUITestOverrides {
52+
static var readinessScore: Int? {
53+
value(for: "-UITestReadinessScore").flatMap(Int.init)
54+
}
55+
56+
static var hour: Int? {
57+
guard let parsed = value(for: "-UITestHour").flatMap(Int.init),
58+
(0..<24).contains(parsed) else {
59+
return nil
60+
}
61+
return parsed
62+
}
63+
64+
static var useDesignB: Bool {
65+
CommandLine.arguments.contains("-UITest_UseDesignB")
66+
}
67+
68+
private static func value(for flag: String) -> String? {
69+
guard let index = CommandLine.arguments.firstIndex(of: flag),
70+
index + 1 < CommandLine.arguments.count else {
71+
return nil
72+
}
73+
return CommandLine.arguments[index + 1]
74+
}
75+
}

apps/HeartCoach/Shared/Views/ThumpBuddy.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ enum BuddyMood: String, Equatable, Sendable {
4848
readinessScore: Int? = nil,
4949
nudgeCompleted: Bool = false,
5050
feedbackType: DailyFeedback? = nil,
51-
activityInProgress: Bool = false
51+
activityInProgress: Bool = false,
52+
currentHour: Int? = nil
5253
) -> BuddyMood {
5354
if nudgeCompleted { return .conquering }
5455
if feedbackType == .positive { return .conquering }
@@ -69,7 +70,7 @@ enum BuddyMood: String, Equatable, Sendable {
6970
}
7071
// Low readiness (< 40): genuinely tired — BUT only show sleeping
7172
// mood in evening hours. During daytime, show nudging instead.
72-
let hour = Calendar.current.component(.hour, from: Date())
73+
let hour = currentHour ?? Calendar.current.component(.hour, from: Date())
7374
let isEvening = hour >= 20 || hour < 6
7475
return isEvening ? .tired : .nudging
7576
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import XCTest
2+
@testable import Thump
3+
4+
final class DashboardHeroPresentationTests: XCTestCase {
5+
6+
func testNightPresentation_usesRestfulMoodForRecoveringScore() {
7+
let mood = DashboardHeroPresentation.mood(
8+
assessment: makeAssessment(status: .stable, stressFlag: false),
9+
readinessScore: 55,
10+
hour: 22
11+
)
12+
13+
XCTAssertEqual(mood, .tired)
14+
}
15+
16+
func testDayPresentation_keepsDaytimeMoodForRecoveringScore() {
17+
let mood = DashboardHeroPresentation.mood(
18+
assessment: makeAssessment(status: .stable, stressFlag: false),
19+
readinessScore: 55,
20+
hour: 10
21+
)
22+
23+
XCTAssertEqual(mood, .nudging)
24+
}
25+
26+
func testNightPresentation_usesRestfulMoodEvenWhenReadinessIsHigh() {
27+
let mood = DashboardHeroPresentation.mood(
28+
assessment: makeAssessment(status: .improving, stressFlag: false),
29+
readinessScore: 88,
30+
hour: 23
31+
)
32+
33+
XCTAssertEqual(mood, .tired)
34+
}
35+
36+
func testNightPresentation_preservesStressedMoodWhenStressIsHigh() {
37+
let mood = DashboardHeroPresentation.mood(
38+
assessment: makeAssessment(status: .needsAttention, stressFlag: true),
39+
readinessScore: 55,
40+
hour: 23
41+
)
42+
43+
XCTAssertEqual(mood, .stressed)
44+
}
45+
46+
private func makeAssessment(status: TrendStatus, stressFlag: Bool) -> HeartAssessment {
47+
let nudge = DailyNudge(
48+
category: .rest,
49+
title: "Rest",
50+
description: "Take it easy tonight.",
51+
durationMinutes: nil,
52+
icon: "bed.double.fill"
53+
)
54+
55+
return HeartAssessment(
56+
status: status,
57+
confidence: .high,
58+
anomalyScore: stressFlag ? 2.0 : 0.2,
59+
regressionFlag: stressFlag,
60+
stressFlag: stressFlag,
61+
cardioScore: 60,
62+
dailyNudge: nudge,
63+
dailyNudges: [nudge],
64+
explanation: "Test assessment"
65+
)
66+
}
67+
}

apps/HeartCoach/UITests/BuddyShowcaseTests.swift

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ import XCTest
22

33
final class BuddyShowcaseTests: XCTestCase {
44

5-
func testCaptureDashboardBuddy() throws {
5+
private func launchApp() -> XCUIApplication {
66
let app = XCUIApplication()
7-
app.launchArguments = ["-UITestMode", "-startTab", "0"]
7+
app.launchArguments = ["-UITestMode", "-UITest_UseDesignB", "-startTab", "0"]
88
app.launch()
9+
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
10+
return app
11+
}
12+
13+
func testCaptureDashboardBuddy() throws {
14+
let app = launchApp()
915

1016
sleep(4) // Let animations settle
1117

@@ -17,9 +23,7 @@ final class BuddyShowcaseTests: XCTestCase {
1723
}
1824

1925
func testCaptureAllTabs() throws {
20-
let app = XCUIApplication()
21-
app.launchArguments = ["-UITestMode", "-startTab", "0"]
22-
app.launch()
26+
let app = launchApp()
2327
sleep(3)
2428

2529
// Dashboard (Home)
@@ -29,31 +33,32 @@ final class BuddyShowcaseTests: XCTestCase {
2933
add(shot1)
3034

3135
// Insights tab
32-
app.tabBars.buttons.element(boundBy: 1).tap()
36+
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10))
37+
app.tabBars.buttons["Insights"].tap()
3338
sleep(2)
3439
let shot2 = XCTAttachment(screenshot: app.screenshot())
3540
shot2.name = "Tab_Insights"
3641
shot2.lifetime = .keepAlways
3742
add(shot2)
3843

3944
// Stress tab
40-
app.tabBars.buttons.element(boundBy: 2).tap()
45+
app.tabBars.buttons["Stress"].tap()
4146
sleep(2)
4247
let shot3 = XCTAttachment(screenshot: app.screenshot())
4348
shot3.name = "Tab_Stress"
4449
shot3.lifetime = .keepAlways
4550
add(shot3)
4651

4752
// Trends tab
48-
app.tabBars.buttons.element(boundBy: 3).tap()
53+
app.tabBars.buttons["Trends"].tap()
4954
sleep(2)
5055
let shot4 = XCTAttachment(screenshot: app.screenshot())
5156
shot4.name = "Tab_Trends"
5257
shot4.lifetime = .keepAlways
5358
add(shot4)
5459

5560
// Settings tab
56-
app.tabBars.buttons.element(boundBy: 4).tap()
61+
app.tabBars.buttons["Settings"].tap()
5762
sleep(2)
5863
let shot5 = XCTAttachment(screenshot: app.screenshot())
5964
shot5.name = "Tab_Settings"

apps/HeartCoach/UITests/ClickableValidationTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,55 @@ final class ClickableValidationTests: XCTestCase {
467467
XCTAssertTrue(hasLongText, "Stressed state must show a mission sentence on Home screen")
468468
}
469469

470+
func testDesignBHomeNightState_usesRestfulBuddy() {
471+
app.terminate()
472+
app.launchArguments = [
473+
"-UITestMode",
474+
"-UITest_UseDesignB",
475+
"-startTab", "0",
476+
"-UITestHour", "22",
477+
"-UITestReadinessScore", "55"
478+
]
479+
app.launch()
480+
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
481+
482+
navigateToTab("Home")
483+
let hero = app.otherElements["dashboard_hero"]
484+
XCTAssertTrue(hero.waitForExistence(timeout: 5), "Home hero should be visible on Home")
485+
486+
screenshot("design_b_night_buddy")
487+
488+
XCTAssertTrue(hero.label.contains("Good night"), "Night hero should use the nighttime greeting")
489+
XCTAssertTrue(hero.label.contains("Rest Up"), "Night hero should show the restful buddy mood")
490+
XCTAssertFalse(hero.label.contains("Train Your Heart"), "Night hero should not show the daytime nudging face")
491+
XCTAssertFalse(hero.label.contains("In the Zone"), "Night hero should not show the active face")
492+
}
493+
494+
func testDesignBHomeNightState_overridesHighReadinessEnergy() {
495+
app.terminate()
496+
app.launchArguments = [
497+
"-UITestMode",
498+
"-UITest_UseDesignB",
499+
"-startTab", "0",
500+
"-UITestHour", "22",
501+
"-UITestReadinessScore", "88"
502+
]
503+
app.launch()
504+
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
505+
506+
navigateToTab("Home")
507+
let hero = app.otherElements["dashboard_hero"]
508+
XCTAssertTrue(hero.waitForExistence(timeout: 5), "Home hero should be visible on Home")
509+
510+
screenshot("design_b_night_high_readiness")
511+
512+
XCTAssertTrue(hero.label.contains("Good night"), "Night hero should keep the nighttime greeting")
513+
XCTAssertTrue(hero.label.contains("Rest Up"), "Night hero should force the resting buddy mood at night")
514+
XCTAssertFalse(hero.label.contains("Crushing It"), "Night hero should not show the high-energy thriving face")
515+
XCTAssertFalse(hero.label.contains("Heart Happy"), "Night hero should not show the daytime content face")
516+
XCTAssertFalse(hero.label.contains("In the Zone"), "Night hero should not show the active face")
517+
}
518+
470519
// MARK: - Helpers
471520

472521
private func navigateToTab(_ name: String) {

apps/HeartCoach/iOS/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
<true/>
1717
<key>NSHealthShareUsageDescription</key>
1818
<string>Thump reads your heart rate, HRV, recovery, VO2 max, steps, exercise, and sleep data to generate wellness insights and training suggestions.</string>
19+
<key>NSHealthUpdateUsageDescription</key>
20+
<string>Thump writes debug-only simulator samples so we can validate HealthKit-powered screens end to end during development and testing.</string>
1921
<key>UIApplicationSupportsIndirectInputEvents</key>
2022
<true/>
2123
<key>UILaunchScreen</key>

apps/HeartCoach/iOS/ThumpiOSApp.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,27 @@ struct ThumpiOSApp: App {
4949
// MARK: - Initialization
5050

5151
init() {
52-
FirebaseApp.configure()
52+
Self.configureFirebase()
5353

5454
let store = LocalStore()
5555
_localStore = StateObject(wrappedValue: store)
5656
_notificationService = StateObject(wrappedValue: NotificationService(localStore: store))
5757
}
5858

59+
private static func configureFirebase() {
60+
guard let options = FirebaseOptions.defaultOptions() else {
61+
FirebaseApp.configure()
62+
return
63+
}
64+
65+
if let bundleID = Bundle.main.bundleIdentifier,
66+
options.bundleID != bundleID {
67+
options.bundleID = bundleID
68+
}
69+
70+
FirebaseApp.configure(options: options)
71+
}
72+
5973
// MARK: - Scene
6074

6175
var body: some Scene {

apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,16 @@ final class DashboardViewModel: ObservableObject {
138138
errorMessage = nil
139139
healthDataProvider.clearQueryWarnings()
140140

141+
// Timeout: if refresh takes more than 15s, stop loading and show error
142+
let timeoutTask = Task { @MainActor in
143+
try await Task.sleep(nanoseconds: 15_000_000_000)
144+
if self.isLoading && self.assessment == nil {
145+
AppLogger.engine.warning("Dashboard refresh timed out after 15s")
146+
self.errorMessage = "Loading took too long. Check that Health permissions are granted in Settings > Privacy > Health > Thump."
147+
self.isLoading = false
148+
}
149+
}
150+
141151
do {
142152
// Ensure HealthKit authorization
143153
if !healthDataProvider.isAuthorized {
@@ -296,6 +306,7 @@ final class DashboardViewModel: ObservableObject {
296306
let totalMs = (CFAbsoluteTimeGetCurrent() - refreshStart) * 1000
297307
AppLogger.engine.info("Dashboard refresh complete in \(String(format: "%.0f", totalMs))ms — history=\(history.count) days")
298308

309+
timeoutTask.cancel()
299310
isLoading = false
300311

301312
// Write diagnostic snapshot for bug reports (BUG-070)
@@ -331,6 +342,7 @@ final class DashboardViewModel: ObservableObject {
331342
EngineTelemetryService.shared.uploadTrace(trace)
332343
} catch {
333344
AppLogger.engine.error("Dashboard refresh failed: \(error.localizedDescription)")
345+
timeoutTask.cancel()
334346
errorMessage = error.localizedDescription
335347
isLoading = false
336348
}

0 commit comments

Comments
 (0)