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
75 changes: 75 additions & 0 deletions apps/HeartCoach/Shared/Theme/DashboardHeroPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation

enum DashboardHeroPresentation {
static func greetingPrefix(for hour: Int) -> String {
switch hour {
case 5..<12:
return "Good morning"
case 12..<17:
return "Good afternoon"
case 17..<21:
return "Good evening"
default:
return "Good night"
}
}

static func mood(
assessment: HeartAssessment?,
readinessScore: Int?,
hour: Int
) -> BuddyMood {
guard let assessment else {
return isQuietHours(hour) ? .tired : .content
}

let baseMood = BuddyMood.from(
assessment: assessment,
readinessScore: readinessScore,
currentHour: hour
)

guard isQuietHours(hour) else {
return baseMood
}

switch baseMood {
case .stressed:
return .stressed
case .tired:
return .tired
default:
return .tired
}
}

static func isQuietHours(_ hour: Int) -> Bool {
hour >= 21 || hour < 5
}
}

enum DashboardUITestOverrides {
static var readinessScore: Int? {
value(for: "-UITestReadinessScore").flatMap(Int.init)
}

static var hour: Int? {
guard let parsed = value(for: "-UITestHour").flatMap(Int.init),
(0..<24).contains(parsed) else {
return nil
}
return parsed
}

static var useDesignB: Bool {
CommandLine.arguments.contains("-UITest_UseDesignB")
}

private static func value(for flag: String) -> String? {
guard let index = CommandLine.arguments.firstIndex(of: flag),
index + 1 < CommandLine.arguments.count else {
return nil
}
return CommandLine.arguments[index + 1]
}
}
5 changes: 3 additions & 2 deletions apps/HeartCoach/Shared/Views/ThumpBuddy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ enum BuddyMood: String, Equatable, Sendable {
readinessScore: Int? = nil,
nudgeCompleted: Bool = false,
feedbackType: DailyFeedback? = nil,
activityInProgress: Bool = false
activityInProgress: Bool = false,
currentHour: Int? = nil
) -> BuddyMood {
if nudgeCompleted { return .conquering }
if feedbackType == .positive { return .conquering }
Expand All @@ -69,7 +70,7 @@ enum BuddyMood: String, Equatable, Sendable {
}
// Low readiness (< 40): genuinely tired — BUT only show sleeping
// mood in evening hours. During daytime, show nudging instead.
let hour = Calendar.current.component(.hour, from: Date())
let hour = currentHour ?? Calendar.current.component(.hour, from: Date())
let isEvening = hour >= 20 || hour < 6
return isEvening ? .tired : .nudging
}
Expand Down
67 changes: 67 additions & 0 deletions apps/HeartCoach/Tests/DashboardHeroPresentationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import XCTest
@testable import Thump

final class DashboardHeroPresentationTests: XCTestCase {

func testNightPresentation_usesRestfulMoodForRecoveringScore() {
let mood = DashboardHeroPresentation.mood(
assessment: makeAssessment(status: .stable, stressFlag: false),
readinessScore: 55,
hour: 22
)

XCTAssertEqual(mood, .tired)
}

func testDayPresentation_keepsDaytimeMoodForRecoveringScore() {
let mood = DashboardHeroPresentation.mood(
assessment: makeAssessment(status: .stable, stressFlag: false),
readinessScore: 55,
hour: 10
)

XCTAssertEqual(mood, .nudging)
}

func testNightPresentation_usesRestfulMoodEvenWhenReadinessIsHigh() {
let mood = DashboardHeroPresentation.mood(
assessment: makeAssessment(status: .improving, stressFlag: false),
readinessScore: 88,
hour: 23
)

XCTAssertEqual(mood, .tired)
}

func testNightPresentation_preservesStressedMoodWhenStressIsHigh() {
let mood = DashboardHeroPresentation.mood(
assessment: makeAssessment(status: .needsAttention, stressFlag: true),
readinessScore: 55,
hour: 23
)

XCTAssertEqual(mood, .stressed)
}

private func makeAssessment(status: TrendStatus, stressFlag: Bool) -> HeartAssessment {
let nudge = DailyNudge(
category: .rest,
title: "Rest",
description: "Take it easy tonight.",
durationMinutes: nil,
icon: "bed.double.fill"
)

return HeartAssessment(
status: status,
confidence: .high,
anomalyScore: stressFlag ? 2.0 : 0.2,
regressionFlag: stressFlag,
stressFlag: stressFlag,
cardioScore: 60,
dailyNudge: nudge,
dailyNudges: [nudge],
explanation: "Test assessment"
)
}
}
23 changes: 14 additions & 9 deletions apps/HeartCoach/UITests/BuddyShowcaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import XCTest

final class BuddyShowcaseTests: XCTestCase {

func testCaptureDashboardBuddy() throws {
private func launchApp() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["-UITestMode", "-startTab", "0"]
app.launchArguments = ["-UITestMode", "-UITest_UseDesignB", "-startTab", "0"]
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
return app
}

func testCaptureDashboardBuddy() throws {
let app = launchApp()

sleep(4) // Let animations settle

Expand All @@ -17,9 +23,7 @@ final class BuddyShowcaseTests: XCTestCase {
}

func testCaptureAllTabs() throws {
let app = XCUIApplication()
app.launchArguments = ["-UITestMode", "-startTab", "0"]
app.launch()
let app = launchApp()
sleep(3)

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

// Insights tab
app.tabBars.buttons.element(boundBy: 1).tap()
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 10))
app.tabBars.buttons["Insights"].tap()
sleep(2)
let shot2 = XCTAttachment(screenshot: app.screenshot())
shot2.name = "Tab_Insights"
shot2.lifetime = .keepAlways
add(shot2)

// Stress tab
app.tabBars.buttons.element(boundBy: 2).tap()
app.tabBars.buttons["Stress"].tap()
sleep(2)
let shot3 = XCTAttachment(screenshot: app.screenshot())
shot3.name = "Tab_Stress"
shot3.lifetime = .keepAlways
add(shot3)

// Trends tab
app.tabBars.buttons.element(boundBy: 3).tap()
app.tabBars.buttons["Trends"].tap()
sleep(2)
let shot4 = XCTAttachment(screenshot: app.screenshot())
shot4.name = "Tab_Trends"
shot4.lifetime = .keepAlways
add(shot4)

// Settings tab
app.tabBars.buttons.element(boundBy: 4).tap()
app.tabBars.buttons["Settings"].tap()
sleep(2)
let shot5 = XCTAttachment(screenshot: app.screenshot())
shot5.name = "Tab_Settings"
Expand Down
49 changes: 49 additions & 0 deletions apps/HeartCoach/UITests/ClickableValidationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,55 @@ final class ClickableValidationTests: XCTestCase {
XCTAssertTrue(hasLongText, "Stressed state must show a mission sentence on Home screen")
}

func testDesignBHomeNightState_usesRestfulBuddy() {
app.terminate()
app.launchArguments = [
"-UITestMode",
"-UITest_UseDesignB",
"-startTab", "0",
"-UITestHour", "22",
"-UITestReadinessScore", "55"
]
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))

navigateToTab("Home")
let hero = app.otherElements["dashboard_hero"]
XCTAssertTrue(hero.waitForExistence(timeout: 5), "Home hero should be visible on Home")

screenshot("design_b_night_buddy")

XCTAssertTrue(hero.label.contains("Good night"), "Night hero should use the nighttime greeting")
XCTAssertTrue(hero.label.contains("Rest Up"), "Night hero should show the restful buddy mood")
XCTAssertFalse(hero.label.contains("Train Your Heart"), "Night hero should not show the daytime nudging face")
XCTAssertFalse(hero.label.contains("In the Zone"), "Night hero should not show the active face")
}

func testDesignBHomeNightState_overridesHighReadinessEnergy() {
app.terminate()
app.launchArguments = [
"-UITestMode",
"-UITest_UseDesignB",
"-startTab", "0",
"-UITestHour", "22",
"-UITestReadinessScore", "88"
]
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))

navigateToTab("Home")
let hero = app.otherElements["dashboard_hero"]
XCTAssertTrue(hero.waitForExistence(timeout: 5), "Home hero should be visible on Home")

screenshot("design_b_night_high_readiness")

XCTAssertTrue(hero.label.contains("Good night"), "Night hero should keep the nighttime greeting")
XCTAssertTrue(hero.label.contains("Rest Up"), "Night hero should force the resting buddy mood at night")
XCTAssertFalse(hero.label.contains("Crushing It"), "Night hero should not show the high-energy thriving face")
XCTAssertFalse(hero.label.contains("Heart Happy"), "Night hero should not show the daytime content face")
XCTAssertFalse(hero.label.contains("In the Zone"), "Night hero should not show the active face")
}

// MARK: - Helpers

private func navigateToTab(_ name: String) {
Expand Down
2 changes: 2 additions & 0 deletions apps/HeartCoach/iOS/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<true/>
<key>NSHealthShareUsageDescription</key>
<string>Thump reads your heart rate, HRV, recovery, VO2 max, steps, exercise, and sleep data to generate wellness insights and training suggestions.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Thump writes debug-only simulator samples so we can validate HealthKit-powered screens end to end during development and testing.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
Expand Down
16 changes: 15 additions & 1 deletion apps/HeartCoach/iOS/ThumpiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,27 @@ struct ThumpiOSApp: App {
// MARK: - Initialization

init() {
FirebaseApp.configure()
Self.configureFirebase()

let store = LocalStore()
_localStore = StateObject(wrappedValue: store)
_notificationService = StateObject(wrappedValue: NotificationService(localStore: store))
}

private static func configureFirebase() {
guard let options = FirebaseOptions.defaultOptions() else {
FirebaseApp.configure()
return
}

if let bundleID = Bundle.main.bundleIdentifier,
options.bundleID != bundleID {
options.bundleID = bundleID
}

FirebaseApp.configure(options: options)
}

// MARK: - Scene

var body: some Scene {
Expand Down
12 changes: 12 additions & 0 deletions apps/HeartCoach/iOS/ViewModels/DashboardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ final class DashboardViewModel: ObservableObject {
errorMessage = nil
healthDataProvider.clearQueryWarnings()

// Timeout: if refresh takes more than 15s, stop loading and show error
let timeoutTask = Task { @MainActor in
try await Task.sleep(nanoseconds: 15_000_000_000)
if self.isLoading && self.assessment == nil {
AppLogger.engine.warning("Dashboard refresh timed out after 15s")
self.errorMessage = "Loading took too long. Check that Health permissions are granted in Settings > Privacy > Health > Thump."
self.isLoading = false
}
}

do {
// Ensure HealthKit authorization
if !healthDataProvider.isAuthorized {
Expand Down Expand Up @@ -296,6 +306,7 @@ final class DashboardViewModel: ObservableObject {
let totalMs = (CFAbsoluteTimeGetCurrent() - refreshStart) * 1000
AppLogger.engine.info("Dashboard refresh complete in \(String(format: "%.0f", totalMs))ms — history=\(history.count) days")

timeoutTask.cancel()
isLoading = false

// Write diagnostic snapshot for bug reports (BUG-070)
Expand Down Expand Up @@ -331,6 +342,7 @@ final class DashboardViewModel: ObservableObject {
EngineTelemetryService.shared.uploadTrace(trace)
} catch {
AppLogger.engine.error("Dashboard refresh failed: \(error.localizedDescription)")
timeoutTask.cancel()
errorMessage = error.localizedDescription
isLoading = false
}
Expand Down
Loading
Loading