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
27 changes: 26 additions & 1 deletion apps/HeartCoach/Tests/ProactiveNotificationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class ProactiveNotificationTests: XCTestCase {

private var localStore: LocalStore!
private var service: ProactiveNotificationService!
private var notificationCenter: TestProactiveNotificationCenter!
private var suiteName: String!
private let config = ProactiveNotificationConfig()

Expand All @@ -25,9 +26,15 @@ final class ProactiveNotificationTests: XCTestCase {
defaults.removePersistentDomain(forName: suiteName)
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
localStore = LocalStore(defaults: defaults)
notificationCenter = TestProactiveNotificationCenter()
let fixedNow = Calendar(identifier: .gregorian).date(
from: DateComponents(year: 2026, month: 4, day: 8, hour: 9, minute: 0)
) ?? Date()
service = ProactiveNotificationService(
center: notificationCenter,
localStore: localStore,
config: config
config: config,
now: { fixedNow }
)
}

Expand All @@ -38,6 +45,7 @@ final class ProactiveNotificationTests: XCTestCase {
}
suiteName = nil
service = nil
notificationCenter = nil
localStore = nil
super.tearDown()
}
Expand Down Expand Up @@ -315,3 +323,20 @@ final class ProactiveNotificationTests: XCTestCase {
XCTAssertGreaterThan(dates.count, 0, "Smoke test: morning briefing should schedule")
}
}

private final class TestProactiveNotificationCenter: ProactiveNotificationCenter {
private var pending: [UNNotificationRequest] = []

func pendingNotificationRequests() async -> [UNNotificationRequest] {
pending
}

func add(_ request: UNNotificationRequest) async throws {
pending.removeAll { $0.identifier == request.identifier }
pending.append(request)
}

func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
pending.removeAll { identifiers.contains($0.identifier) }
}
}
13 changes: 10 additions & 3 deletions apps/HeartCoach/Tests/RealWorldDataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,17 @@ final class RealWorldDataTests: XCTestCase {
// MARK: Weekend warrior pattern

func testRealistic_weekendWarrior_noFalseAlarms() {
// Build 30 days: sedentary Mon-Fri, very active Sat-Sun
// Build 30 days: sedentary Mon-Fri, very active Sat-Sun.
// Use a fixed anchor date so weekday/weekend alignment is deterministic
// and independent of when CI executes.
let calendar = Calendar(identifier: .gregorian)
let referenceSunday = calendar.date(
from: DateComponents(year: 2026, month: 3, day: 29)
)!

let data: [HeartSnapshot] = (0..<30).map { day in
let date = Calendar.current.date(byAdding: .day, value: -29 + day, to: Date())!
let weekday = Calendar.current.component(.weekday, from: date)
let date = calendar.date(byAdding: .day, value: -29 + day, to: referenceSunday)!
let weekday = calendar.component(.weekday, from: date)
let isWeekend = weekday == 1 || weekday == 7
var rng = SeededRNG(seed: 2000 + UInt64(day))

Expand Down
33 changes: 24 additions & 9 deletions apps/HeartCoach/iOS/Services/ProactiveNotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
import Foundation
import UserNotifications

// MARK: - Notification Center Abstraction

/// Small abstraction to make scheduling logic testable without relying on
/// simulator/system notification authorization state.
protocol ProactiveNotificationCenter {
func pendingNotificationRequests() async -> [UNNotificationRequest]
func add(_ request: UNNotificationRequest) async throws
func removePendingNotificationRequests(withIdentifiers identifiers: [String])
}

extension UNUserNotificationCenter: ProactiveNotificationCenter {}

// MARK: - Configuration

/// All thresholds in one testable struct — no magic numbers (Gemini design).
Expand Down Expand Up @@ -96,24 +108,27 @@ final class ProactiveNotificationService: ObservableObject {

// MARK: - Dependencies

private let center: UNUserNotificationCenter
private let center: any ProactiveNotificationCenter
private let localStore: LocalStore
private let config: ProactiveNotificationConfig
private let calendar: Calendar
private let now: @Sendable () -> Date
private let gate = ProactiveSchedulingGate()

// MARK: - Initialization

init(
center: UNUserNotificationCenter = .current(),
center: any ProactiveNotificationCenter = UNUserNotificationCenter.current(),
localStore: LocalStore,
config: ProactiveNotificationConfig = ProactiveNotificationConfig(),
calendar: Calendar = .current
calendar: Calendar = .current,
now: @escaping @Sendable () -> Date = Date.init
) {
self.center = center
self.localStore = localStore
self.config = config
self.calendar = calendar
self.now = now
}

// MARK: - 1. Morning Readiness Briefing
Expand All @@ -129,7 +144,7 @@ final class ProactiveNotificationService: ObservableObject {
guard await canSchedule(type: type, snapshotDate: snapshotDate) else { return }

// Only fire before noon
let hour = calendar.component(.hour, from: Date())
let hour = calendar.component(.hour, from: now())
guard hour < 12 else { return }

let levelWord: String
Expand Down Expand Up @@ -241,7 +256,7 @@ final class ProactiveNotificationService: ObservableObject {
!overtrained else { return }

// Weekly cap
let weekAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
let weekAgo = calendar.date(byAdding: .day, value: -7, to: now()) ?? now()
let recentCount = localStore.proactiveNotificationDates(for: type)
.filter { $0 > weekAgo }
.count
Expand Down Expand Up @@ -272,7 +287,7 @@ final class ProactiveNotificationService: ObservableObject {

// Strict cooldown: max 1 per 48h
if let lastSent = localStore.proactiveNotificationDates(for: type).max() {
let hoursSince = Date().timeIntervalSince(lastSent) / 3600
let hoursSince = now().timeIntervalSince(lastSent) / 3600
guard hoursSince >= config.illnessDetectionCooldownHours else { return }
}

Expand Down Expand Up @@ -373,12 +388,12 @@ final class ProactiveNotificationService: ObservableObject {
) async -> Bool {
// Data freshness
if let snapshotDate {
let staleHours = Date().timeIntervalSince(snapshotDate) / 3600
let staleHours = now().timeIntervalSince(snapshotDate) / 3600
guard staleHours < config.morningBriefingStaleHours else { return false }
}

// Daily budget (GPT-5.4 fix #6)
let today = calendar.startOfDay(for: Date())
let today = calendar.startOfDay(for: now())
let todayCount = ProactiveNotificationType.allCases
.flatMap { localStore.proactiveNotificationDates(for: $0) }
.filter { $0 >= today }
Expand Down Expand Up @@ -415,7 +430,7 @@ final class ProactiveNotificationService: ObservableObject {

do {
try await center.add(request)
localStore.logProactiveNotification(type: type, at: Date())
localStore.logProactiveNotification(type: type, at: now())
AppLogger.info("[ProactiveNotification] Scheduled: \(type.rawValue)")
} catch {
AppLogger.engine.warning("[ProactiveNotification] Failed to schedule \(type.rawValue): \(error.localizedDescription)")
Expand Down
Loading