Fix 7 data mutation layer risks identified in audit
- save()/saveAndRunDataListeners() now return @discardableResult Bool; listeners only fire on successful save - MoodLogger.updateMood() now recalculates streak, updates Live Activity, and notifies watch (was missing these side effects) - CSV import uses new addBatch()/importMoods() for O(1) side effects instead of O(n) per-row widget reloads and streak calcs - Foreground task ordering: fillInMissingDates() now runs before removeDuplicates() so backfill-created duplicates are caught same cycle - WidgetMoodSaver deletes ALL entries for date (was fetchLimit=1, leaving CloudKit sync duplicates behind) - cleanupPhotoIfNeeded logs warning on failed photo deletion instead of silently orphaning files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,110 @@ final class MoodLogger {
|
||||
markSideEffectsApplied(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() {
|
||||
@@ -127,6 +231,19 @@ final class MoodLogger {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user