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:
Trey t
2026-02-14 23:09:11 -06:00
parent 3023475f66
commit f1cd81c395
8 changed files with 306 additions and 55 deletions

View File

@@ -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