// // WeatherManager.swift // Reflect // // WeatherKit fetch service for attaching weather data to mood entries. // import Foundation import WeatherKit import CoreLocation import os.log @MainActor final class WeatherManager { static let shared = WeatherManager() private static let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "WeatherManager" ) private static let retryQueueKey = "weatherRetryQueue" private init() {} // MARK: - Fetch Weather func fetchWeather(for date: Date, at location: CLLocation) async throws -> WeatherData { let calendar = Calendar.current let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! let (daily, current) = try await WeatherService.shared.weather( for: location, including: .daily(startDate: startOfDay, endDate: endOfDay), .current ) guard let dayWeather = daily.forecast.first else { throw WeatherError.noDataAvailable } return WeatherData( conditionSymbol: dayWeather.symbolName, condition: dayWeather.condition.description, temperature: (dayWeather.highTemperature.value + dayWeather.lowTemperature.value) / 2.0, highTemperature: dayWeather.highTemperature.value, lowTemperature: dayWeather.lowTemperature.value, humidity: current.humidity, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude, fetchedAt: Date() ) } // MARK: - Fetch and Save func fetchAndSaveWeather(for date: Date) async { do { let location = try await LocationManager.shared.currentLocation let weatherData = try await fetchWeather(for: date, at: location) guard let json = weatherData.encode() else { Self.logger.error("Failed to encode weather data") return } DataController.shared.updateWeather(forDate: date, weatherJSON: json) AnalyticsManager.shared.track(.weatherFetched) Self.logger.info("Weather saved for \(date)") } catch let error as LocationManager.LocationError where error == .permissionDenied { Self.logger.warning("Weather fetch skipped: location permission denied") AnalyticsManager.shared.track(.weatherFetchFailed(error: "location_permission_denied")) // Don't add to retry queue — retrying won't help until user grants permission } catch { Self.logger.error("Weather fetch failed: \(error.localizedDescription)") AnalyticsManager.shared.track(.weatherFetchFailed(error: error.localizedDescription)) addToRetryQueue(date: date) } } // MARK: - Retry Queue func retryPendingWeatherFetches() async { // Bail early if location permission is denied — retrying will always fail let status = LocationManager.shared.authorizationStatus if status == .denied || status == .restricted { Self.logger.info("Clearing weather retry queue — location permission denied") saveRetryQueue([]) return } let dates = getRetryQueue() guard !dates.isEmpty else { return } Self.logger.info("Retrying weather fetch for \(dates.count) entries") var remainingDates: [Date] = [] for date in dates { do { let location = try await LocationManager.shared.currentLocation let weatherData = try await fetchWeather(for: date, at: location) guard let json = weatherData.encode() else { continue } DataController.shared.updateWeather(forDate: date, weatherJSON: json) Self.logger.info("Retry succeeded for \(date)") } catch { Self.logger.error("Retry failed for \(date): \(error.localizedDescription)") remainingDates.append(date) } } saveRetryQueue(remainingDates) } private func addToRetryQueue(date: Date) { var queue = getRetryQueue() let startOfDay = Calendar.current.startOfDay(for: date) // Don't add duplicates guard !queue.contains(where: { Calendar.current.isDate($0, inSameDayAs: startOfDay) }) else { return } queue.append(startOfDay) // Keep only last 7 days of retries let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date())! queue = queue.filter { $0 > sevenDaysAgo } saveRetryQueue(queue) } private func getRetryQueue() -> [Date] { guard let strings = GroupUserDefaults.groupDefaults.stringArray(forKey: Self.retryQueueKey) else { return [] } let formatter = ISO8601DateFormatter() return strings.compactMap { formatter.date(from: $0) } } private func saveRetryQueue(_ dates: [Date]) { let formatter = ISO8601DateFormatter() let strings = dates.map { formatter.string(from: $0) } GroupUserDefaults.groupDefaults.set(strings, forKey: Self.retryQueueKey) } // MARK: - Error enum WeatherError: LocalizedError { case noDataAvailable case locationDenied var errorDescription: String? { switch self { case .noDataAvailable: return "No weather data available for the requested date" case .locationDenied: return "Location permission denied — cannot fetch weather" } } } }