1313import Foundation
1414import UserNotifications
1515
16+ // MARK: - Notification Center Abstraction
17+
18+ /// Small abstraction to make scheduling logic testable without relying on
19+ /// simulator/system notification authorization state.
20+ protocol ProactiveNotificationCenter {
21+ func pendingNotificationRequests( ) async -> [ UNNotificationRequest ]
22+ func add( _ request: UNNotificationRequest ) async throws
23+ func removePendingNotificationRequests( withIdentifiers identifiers: [ String ] )
24+ }
25+
26+ extension UNUserNotificationCenter : ProactiveNotificationCenter { }
27+
1628// MARK: - Configuration
1729
1830/// All thresholds in one testable struct — no magic numbers (Gemini design).
@@ -96,24 +108,27 @@ final class ProactiveNotificationService: ObservableObject {
96108
97109 // MARK: - Dependencies
98110
99- private let center : UNUserNotificationCenter
111+ private let center : any ProactiveNotificationCenter
100112 private let localStore : LocalStore
101113 private let config : ProactiveNotificationConfig
102114 private let calendar : Calendar
115+ private let now : @Sendable ( ) -> Date
103116 private let gate = ProactiveSchedulingGate ( )
104117
105118 // MARK: - Initialization
106119
107120 init (
108- center: UNUserNotificationCenter = . current( ) ,
121+ center: any ProactiveNotificationCenter = UNUserNotificationCenter . current ( ) ,
109122 localStore: LocalStore ,
110123 config: ProactiveNotificationConfig = ProactiveNotificationConfig ( ) ,
111- calendar: Calendar = . current
124+ calendar: Calendar = . current,
125+ now: @escaping @Sendable ( ) -> Date = Date . init
112126 ) {
113127 self . center = center
114128 self . localStore = localStore
115129 self . config = config
116130 self . calendar = calendar
131+ self . now = now
117132 }
118133
119134 // MARK: - 1. Morning Readiness Briefing
@@ -129,7 +144,7 @@ final class ProactiveNotificationService: ObservableObject {
129144 guard await canSchedule ( type: type, snapshotDate: snapshotDate) else { return }
130145
131146 // Only fire before noon
132- let hour = calendar. component ( . hour, from: Date ( ) )
147+ let hour = calendar. component ( . hour, from: now ( ) )
133148 guard hour < 12 else { return }
134149
135150 let levelWord : String
@@ -241,7 +256,7 @@ final class ProactiveNotificationService: ObservableObject {
241256 !overtrained else { return }
242257
243258 // Weekly cap
244- let weekAgo = calendar. date ( byAdding: . day, value: - 7 , to: Date ( ) ) ?? Date ( )
259+ let weekAgo = calendar. date ( byAdding: . day, value: - 7 , to: now ( ) ) ?? now ( )
245260 let recentCount = localStore. proactiveNotificationDates ( for: type)
246261 . filter { $0 > weekAgo }
247262 . count
@@ -272,7 +287,7 @@ final class ProactiveNotificationService: ObservableObject {
272287
273288 // Strict cooldown: max 1 per 48h
274289 if let lastSent = localStore. proactiveNotificationDates ( for: type) . max ( ) {
275- let hoursSince = Date ( ) . timeIntervalSince ( lastSent) / 3600
290+ let hoursSince = now ( ) . timeIntervalSince ( lastSent) / 3600
276291 guard hoursSince >= config. illnessDetectionCooldownHours else { return }
277292 }
278293
@@ -373,12 +388,12 @@ final class ProactiveNotificationService: ObservableObject {
373388 ) async -> Bool {
374389 // Data freshness
375390 if let snapshotDate {
376- let staleHours = Date ( ) . timeIntervalSince ( snapshotDate) / 3600
391+ let staleHours = now ( ) . timeIntervalSince ( snapshotDate) / 3600
377392 guard staleHours < config. morningBriefingStaleHours else { return false }
378393 }
379394
380395 // Daily budget (GPT-5.4 fix #6)
381- let today = calendar. startOfDay ( for: Date ( ) )
396+ let today = calendar. startOfDay ( for: now ( ) )
382397 let todayCount = ProactiveNotificationType . allCases
383398 . flatMap { localStore. proactiveNotificationDates ( for: $0) }
384399 . filter { $0 >= today }
@@ -415,7 +430,7 @@ final class ProactiveNotificationService: ObservableObject {
415430
416431 do {
417432 try await center. add ( request)
418- localStore. logProactiveNotification ( type: type, at: Date ( ) )
433+ localStore. logProactiveNotification ( type: type, at: now ( ) )
419434 AppLogger . info ( " [ProactiveNotification] Scheduled: \( type. rawValue) " )
420435 } catch {
421436 AppLogger . engine. warning ( " [ProactiveNotification] Failed to schedule \( type. rawValue) : \( error. localizedDescription) " )
0 commit comments