Files
Reflect/Shared/Persisence/DataControllerADD.swift
Trey t f1cd81c395 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>
2026-02-14 23:09:11 -06:00

125 lines
4.4 KiB
Swift

//
// DataControllerADD.swift
// Feels
//
// SwiftData CREATE operations.
//
import SwiftData
import Foundation
import os.log
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.feels", category: "DataControllerADD")
extension DataController {
func add(mood: Mood, forDate date: Date, entryType: EntryType) {
// Delete ALL existing entries for this date (handles duplicates)
let existing = getAllEntries(byDate: date)
for entry in existing {
cleanupPhotoIfNeeded(for: entry)
modelContext.delete(entry)
}
if !existing.isEmpty {
do {
try modelContext.save()
} catch {
logger.error("Failed to save after deleting existing entries: \(error.localizedDescription)")
}
}
let entry = MoodEntryModel(
forDate: date,
mood: mood,
entryType: entryType
)
modelContext.insert(entry)
AnalyticsManager.shared.track(.moodLogged(mood: mood.rawValue, entryType: String(describing: entryType)))
saveAndRunDataListeners()
}
func fillInMissingDates() {
let currentOnboarding = UserDefaultsStore.getOnboarding()
var endDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: currentOnboarding)
// Since it's for views, take away the last date so vote is enabled
guard let adjustedEndDate = Calendar.current.date(byAdding: .day, value: -1, to: endDate) else {
logger.error("Failed to calculate adjusted end date")
return
}
endDate = adjustedEndDate
let descriptor = FetchDescriptor<MoodEntryModel>(
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
)
let entries: [MoodEntryModel]
do {
entries = try modelContext.fetch(descriptor)
} catch {
logger.error("Failed to fetch entries for fill-in: \(error.localizedDescription)")
return
}
guard let firstEntry = entries.last else { return }
let allDates: [Date] = Date.dates(from: firstEntry.forDate, toDate: endDate, includingToDate: true).compactMap {
Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: $0)
}
let existingDates: Set<Date> = Set(entries.compactMap {
Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: $0.forDate)
})
let missing = Array(Set(allDates).subtracting(existingDates)).sorted(by: >)
guard !missing.isEmpty else { return }
// Batch insert all missing dates without triggering listeners
for date in missing {
// Add 12 hours to avoid UTC offset issues
guard let adjustedDate = Calendar.current.date(byAdding: .hour, value: 12, to: date) else {
logger.error("Failed to calculate adjusted date for \(date)")
continue
}
let entry = MoodEntryModel(
forDate: adjustedDate,
mood: .missing,
entryType: .filledInMissing
)
modelContext.insert(entry)
}
// Single save and listener notification at the end
saveAndRunDataListeners()
AnalyticsManager.shared.track(.missingEntriesFilled(count: missing.count))
}
func fixWrongWeekdays() {
let data = getData(startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [])
for entry in data {
entry.weekDay = Calendar.current.component(.weekday, from: entry.forDate)
}
saveAndRunDataListeners()
}
func removeNoForDates() {
// Note: With SwiftData's non-optional forDate, this is essentially a no-op
// Keeping for API compatibility
}
/// Batch insert mood entries without per-entry analytics or listener notifications.
/// Used for CSV import where side effects should fire once at the end.
func addBatch(entries: [(mood: Mood, date: Date, entryType: EntryType)]) {
for (mood, date, entryType) in entries {
let existing = getAllEntries(byDate: date)
for entry in existing {
cleanupPhotoIfNeeded(for: entry)
modelContext.delete(entry)
}
let entry = MoodEntryModel(forDate: date, mood: mood, entryType: entryType)
modelContext.insert(entry)
}
saveAndRunDataListeners()
}
}