// // MoodLogger.swift // Reflect // // Centralized mood logging service that handles all side effects // import Foundation import WidgetKit import os.log /// Centralized service for logging moods with all associated side effects. /// All mood entry points should use this service to ensure consistent behavior. @MainActor final class MoodLogger { static let shared = MoodLogger() private static let logger = Logger(subsystem: "com.88oakapps.reflect", category: "MoodLogger") /// Key for tracking the last date side effects were applied private static let lastSideEffectsDateKey = "lastSideEffectsAppliedDate" private static let sideEffectsDateFormatter = ISO8601DateFormatter() private init() {} /// Log a mood entry with all associated side effects. /// This is the single source of truth for mood logging in the app. /// /// - Parameters: /// - mood: The mood to log /// - date: The date for the mood entry /// - entryType: The source of the mood entry (header, widget, siri, etc.) /// - syncHealthKit: Whether to sync to HealthKit (default true, but widget can't access HealthKit) /// - updateTips: Whether to update TipKit parameters (default true, but widget can't access TipKit) func logMood( _ mood: Mood, for date: Date, entryType: EntryType, syncHealthKit: Bool = true, updateTips: Bool = true ) { // 1. Add mood entry to data store DataController.shared.add(mood: mood, forDate: date, entryType: entryType) // Apply side effects and mark as complete applySideEffects(mood: mood, for: date, syncHealthKit: syncHealthKit, updateTips: updateTips) } /// Apply side effects for a mood entry without saving the entry itself. /// Used for catch-up when widget saved data but couldn't run side effects. /// /// - Parameters: /// - mood: The mood that was logged /// - date: The date of the mood entry /// - syncHealthKit: Whether to sync to HealthKit /// - updateTips: Whether to update TipKit parameters func applySideEffects( mood: Mood, for date: Date, syncHealthKit: Bool = true, updateTips: Bool = true ) { // Skip side effects for placeholder/missing moods guard mood != .missing && mood != .placeholder else { return } Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)") let hasAccess = !IAPManager.shared.shouldShowPaywall // 1. Sync to HealthKit if enabled, requested, and user has full access if syncHealthKit { let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue) if healthKitEnabled && hasAccess { Task { try? await HealthKitManager.shared.saveMood(mood, for: date) } } } // 2. Calculate current streak for Live Activity and TipKit let streak = calculateCurrentStreak() // 3. Update Live Activity LiveActivityManager.shared.updateActivity(streak: streak, mood: mood) LiveActivityScheduler.shared.invalidateCache() // Clear stale hasRated cache LiveActivityScheduler.shared.scheduleForNextDay() // 4. Update tips parameters if requested if updateTips { ReflectTipsManager.shared.onMoodLogged() ReflectTipsManager.shared.updateStreak(streak) } // 5. Request app review at moments of delight ReviewRequestManager.shared.onMoodLogged(streak: streak) // 6. Reload widgets WidgetCenter.shared.reloadAllTimelines() // 7. Notify watch to refresh complications WatchConnectivityManager.shared.notifyWatchToReload() // 8. Mark side effects as applied for this date markSideEffectsApplied(for: date) // 9. Fetch weather if enabled and user has full access let weatherEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.weatherEnabled.rawValue) if weatherEnabled && hasAccess { Task { await WeatherManager.shared.fetchAndSaveWeather(for: date) } } } /// Delete a mood entry for a specific date with all associated cleanup. /// Replaces the entry with a .missing placeholder and cleans up HealthKit/Live Activity state. /// /// - Parameter date: The date of the entry to delete func deleteMood(forDate date: Date) { // 1. Delete all entries for this date and replace with missing placeholder DataController.shared.deleteAllEntries(forDate: date) DataController.shared.add(mood: .missing, forDate: date, entryType: .filledInMissing) Self.logger.info("Deleted mood entry for \(date)") // 2. Delete HealthKit entry if enabled and user has access let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue) let hasAccess = !IAPManager.shared.shouldShowPaywall if healthKitEnabled && hasAccess { Task { try? await HealthKitManager.shared.deleteMood(for: date) } } // 3. Recalculate streak and update Live Activity let streak = calculateCurrentStreak() LiveActivityManager.shared.updateActivity(streak: streak, mood: .missing) LiveActivityScheduler.shared.invalidateCache() LiveActivityScheduler.shared.scheduleBasedOnCurrentTime() // 4. Reload widgets WidgetCenter.shared.reloadAllTimelines() // 5. Notify watch to refresh WatchConnectivityManager.shared.notifyWatchToReload() } /// Delete all mood data with full cleanup of HealthKit and Live Activity state. /// Used when user clears all data from settings. func deleteAllData() { // 1. Clear all entries from the database DataController.shared.clearDB() Self.logger.info("Cleared all mood data") // 2. Delete all HealthKit entries if enabled and user has access let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue) let hasAccess = !IAPManager.shared.shouldShowPaywall if healthKitEnabled && hasAccess { Task { try? await HealthKitManager.shared.deleteAllMoods() } } // 3. End all Live Activities Task { await LiveActivityManager.shared.endAllActivities() } LiveActivityScheduler.shared.invalidateCache() // 4. Reload widgets WidgetCenter.shared.reloadAllTimelines() // 5. Notify watch to refresh WatchConnectivityManager.shared.notifyWatchToReload() } /// Update an existing mood entry with all associated side effects. /// Centralizes the update logic that was previously split between ViewModel and DataController. /// /// - Parameters: /// - entryDate: The date of the entry to update /// - mood: The new mood value /// - Returns: Whether the update was successful @discardableResult func updateMood(entryDate: Date, withMood mood: Mood) -> Bool { let success = DataController.shared.update(entryDate: entryDate, withMood: mood) guard success else { Self.logger.error("Failed to update mood entry for \(entryDate)") return false } // Skip side effects for placeholder/missing moods guard mood != .missing && mood != .placeholder else { return true } // Sync to HealthKit if enabled and user has full access let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue) let hasAccess = !IAPManager.shared.shouldShowPaywall if healthKitEnabled && hasAccess { Task { try? await HealthKitManager.shared.saveMood(mood, for: entryDate) } } // Reload widgets WidgetCenter.shared.reloadAllTimelines() // Recalculate streak and update Live Activity let streak = calculateCurrentStreak() LiveActivityManager.shared.updateActivity(streak: streak, mood: mood) LiveActivityScheduler.shared.invalidateCache() // Notify watch to refresh WatchConnectivityManager.shared.notifyWatchToReload() return true } /// Check for and process any pending side effects from widget/extension votes. /// Call this when the app becomes active to ensure all side effects are applied. func processPendingSideEffects() { let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) let dayStart = Calendar.current.startOfDay(for: votingDate) let dayEnd = Calendar.current.date(byAdding: .day, value: 1, to: dayStart)! // Check if there's an entry for the current voting date guard let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first, entry.mood != .missing && entry.mood != .placeholder else { Self.logger.debug("No valid mood entry for today, skipping side effects catch-up") return } // Check if side effects were already applied for this date if sideEffectsApplied(for: votingDate) { Self.logger.debug("Side effects already applied for \(votingDate)") return } // Apply the missing side effects Self.logger.info("Catching up side effects for widget/watch vote: \(entry.mood.rawValue)") applySideEffects(mood: entry.mood, for: entry.forDate) } /// Import mood entries in batch with a single round of side effects. /// Used for CSV import to avoid O(n) widget reloads, streak calcs, etc. func importMoods(_ entries: [(mood: Mood, date: Date, entryType: EntryType)]) { guard !entries.isEmpty else { return } DataController.shared.addBatch(entries: entries) // Single round of side effects WidgetCenter.shared.reloadAllTimelines() WatchConnectivityManager.shared.notifyWatchToReload() LiveActivityScheduler.shared.invalidateCache() } // MARK: - Side Effects Tracking /// Mark that side effects have been applied for a given date private func markSideEffectsApplied(for date: Date) { let dateString = Self.sideEffectsDateFormatter.string(from: Calendar.current.startOfDay(for: date)) GroupUserDefaults.groupDefaults.set(dateString, forKey: Self.lastSideEffectsDateKey) } /// Check if side effects have been applied for a given date private func sideEffectsApplied(for date: Date) -> Bool { guard let lastDateString = GroupUserDefaults.groupDefaults.string(forKey: Self.lastSideEffectsDateKey), let lastDate = Self.sideEffectsDateFormatter.date(from: lastDateString) else { return false } let targetDay = Calendar.current.startOfDay(for: date) let lastDay = Calendar.current.startOfDay(for: lastDate) return targetDay == lastDay } /// Calculate the current mood streak using optimized batch query private func calculateCurrentStreak() -> Int { let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) return DataController.shared.calculateStreak(from: votingDate).streak } }