diff --git a/apps/HeartCoach/Tests/StressViewModelTests.swift b/apps/HeartCoach/Tests/StressViewModelTests.swift index f5b6839..2467258 100644 --- a/apps/HeartCoach/Tests/StressViewModelTests.swift +++ b/apps/HeartCoach/Tests/StressViewModelTests.swift @@ -367,4 +367,58 @@ final class StressViewModelTests: XCTestCase { XCTAssertEqual(vm.assessmentReadinessLevel, .recovering) } + + // MARK: - Hourly Display Filtering + + func testVisibleHourlyPointsForDisplay_todayFiltersFutureHours() { + let vm = StressViewModel() + let calendar = Calendar.current + let now = calendar.date( + bySettingHour: 10, + minute: 15, + second: 0, + of: Date() + ) ?? Date() + let today = calendar.startOfDay(for: now) + + let points = (0..<24).map { hour in + HourlyStressPoint( + date: calendar.date(bySettingHour: hour, minute: 0, second: 0, of: today) ?? today, + hour: hour, + score: 50, + level: .balanced + ) + } + + let visible = vm.visibleHourlyPointsForDisplay(points, on: today, now: now) + XCTAssertEqual(visible.count, 11, "10 AM should show 0...10 only") + XCTAssertEqual(visible.last?.hour, 10) + } + + func testVisibleHourlyPointsForDisplay_pastDayKeepsAllHours() { + let vm = StressViewModel() + let calendar = Calendar.current + let now = calendar.date( + bySettingHour: 10, + minute: 15, + second: 0, + of: Date() + ) ?? Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: now) ?? now + let dayStart = calendar.startOfDay(for: yesterday) + + let points = (0..<24).map { hour in + HourlyStressPoint( + date: calendar.date(bySettingHour: hour, minute: 0, second: 0, of: dayStart) ?? dayStart, + hour: hour, + score: 50, + level: .balanced + ) + } + + let visible = vm.visibleHourlyPointsForDisplay(points, on: dayStart, now: now) + XCTAssertEqual(visible.count, 24) + XCTAssertEqual(visible.first?.hour, 0) + XCTAssertEqual(visible.last?.hour, 23) + } } diff --git a/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift b/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift index e134e37..970cec2 100644 --- a/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift +++ b/apps/HeartCoach/iOS/ViewModels/StressViewModel.swift @@ -217,6 +217,10 @@ final class StressViewModel: ObservableObject { } #endif + // Ensure today is present so day-view stress never falls back to + // yesterday by default when the history query excludes the current day. + snapshots = await mergeWithTodaySnapshot(snapshots) + history = snapshots // When coordinator is available, read pre-computed values @@ -253,10 +257,14 @@ final class StressViewModel: ObservableObject { selectedDayHourlyPoints = [] } else { selectedDayForDetail = date - selectedDayHourlyPoints = engine.hourlyStressForDay( + let points = engine.hourlyStressForDay( snapshots: history, date: date ) + selectedDayHourlyPoints = visibleHourlyPointsForDisplay( + points, + on: date + ) } } @@ -582,9 +590,13 @@ final class StressViewModel: ObservableObject { snapshots: history, date: today ) - if !todayHourly.isEmpty { + let visibleTodayHourly = visibleHourlyPointsForDisplay( + todayHourly, + on: today + ) + if !visibleTodayHourly.isEmpty { hourlyReferenceDate = today - hourlyPoints = todayHourly + hourlyPoints = visibleTodayHourly } else if let latestDate = history.map(\.date).max() { hourlyReferenceDate = latestDate hourlyPoints = engine.hourlyStressForDay( @@ -601,6 +613,49 @@ final class StressViewModel: ObservableObject { selectedDayHourlyPoints = [] } + /// Applies display-time filtering for hourly points. + /// + /// For today, we only show up to the current local hour so future + /// buckets (e.g., 11 PM at 10 AM) are not shown as current data. + /// For past days, all 24 hourly buckets remain visible. + func visibleHourlyPointsForDisplay( + _ points: [HourlyStressPoint], + on date: Date, + now: Date = Date() + ) -> [HourlyStressPoint] { + guard Calendar.current.isDate(date, inSameDayAs: now) else { + return points + } + let currentHour = Calendar.current.component(.hour, from: now) + return points.filter { $0.hour <= currentHour } + } + + /// Adds or replaces today's snapshot so stress day-view calculations + /// are anchored to the current day when available. + private func mergeWithTodaySnapshot(_ snapshots: [HeartSnapshot]) async -> [HeartSnapshot] { + var merged = snapshots + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + do { + let todaySnapshot = try await healthKitService.fetchTodaySnapshot() + if let existingIndex = merged.firstIndex(where: { calendar.isDate($0.date, inSameDayAs: today) }) { + merged[existingIndex] = todaySnapshot + } else { + merged.append(todaySnapshot) + } + } catch { + AppLogger.healthKit.warning("Today snapshot fetch failed: \(error.localizedDescription)") + #if targetEnvironment(simulator) + if !merged.contains(where: { calendar.isDateInToday($0.date) }) { + merged.append(MockData.mockTodaySnapshot) + } + #endif + } + + return merged.sorted { $0.date < $1.date } + } + /// Learn sleep patterns from history. private func learnPatterns() { sleepPatterns = scheduler.learnSleepPatterns(from: history)