Files
Reflect/Shared/Persisence/DataControllerGET.swift
Trey t 7c142568be Fix boundary bugs, route side effects through MoodLogger, add data listeners
- Fix ExtensionDataProvider <= boundary to < in date queries (prevented cross-day leaks)
- Replace force-unwraps with guards and add error logging in DataControllerGET and ExtensionDataProvider
- Route DayViewViewModel update/delete through MoodLogger.shared (was duplicating side effects)
- Add data listeners to InsightsViewModel and YearViewModel for cross-tab refresh
- Add HealthKitManager.deleteMood(for:) for single-date cleanup
- Add SharedModelContainer.isUsingInMemoryFallback flag with critical logging
- Add analytics events: entryDeleted, allDataCleared, duplicatesRemoved, storageFallbackActivated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:34:09 -06:00

134 lines
4.5 KiB
Swift

//
// DataControllerGET.swift
// Feels
//
// SwiftData READ operations.
//
import SwiftData
import Foundation
import os.log
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.feels", category: "DataControllerGET")
extension DataController {
func getEntry(byDate date: Date) -> MoodEntryModel? {
let startDate = Calendar.current.startOfDay(for: date)
guard let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate) else {
logger.error("Failed to calculate end date for getEntry")
return nil
}
var descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
},
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
descriptor.fetchLimit = 1
do {
return try modelContext.fetch(descriptor).first
} catch {
logger.error("Failed to fetch entry: \(error.localizedDescription)")
return nil
}
}
func getData(startDate: Date, endDate: Date, includedDays: [Int]) -> [MoodEntryModel] {
let weekDays = includedDays.isEmpty ? [1, 2, 3, 4, 5, 6, 7] : includedDays
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate &&
entry.forDate <= endDate &&
weekDays.contains(entry.weekDay)
},
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
do {
return try modelContext.fetch(descriptor)
} catch {
logger.error("Failed to fetch entries: \(error.localizedDescription)")
return []
}
}
/// Calculate the current mood streak efficiently using a single batch query.
/// Returns a tuple of (streak count, last logged mood if today has entry).
func calculateStreak(from votingDate: Date) -> (streak: Int, todaysMood: Mood?) {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: votingDate)
// Fetch last 365 days of entries in a single query
guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else {
return (0, nil)
}
let entries = getData(startDate: yearAgo, endDate: votingDate, includedDays: [])
.filter { $0.mood != .missing && $0.mood != .placeholder }
guard !entries.isEmpty else { return (0, nil) }
// Build a Set of dates that have valid entries for O(1) lookup
let datesWithEntries = Set(entries.map { calendar.startOfDay(for: $0.forDate) })
// Check for today's entry
let todaysEntry = entries.first { calendar.isDate($0.forDate, inSameDayAs: votingDate) }
let todaysMood = todaysEntry?.mood
// Calculate streak walking backwards
var streak = 0
var checkDate = votingDate
// If no entry for current voting date, start counting from previous day
if !datesWithEntries.contains(dayStart) {
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
}
while true {
let checkDayStart = calendar.startOfDay(for: checkDate)
if datesWithEntries.contains(checkDayStart) {
streak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
} else {
break
}
}
return (streak, todaysMood)
}
func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] {
// Single query to fetch all data - avoid N*12 queries
let data = getData(
startDate: Date(timeIntervalSince1970: 0),
endDate: Date(),
includedDays: includedDays
)
guard !data.isEmpty else { return [:] }
let calendar = Calendar.current
// Group entries by year and month in memory (single pass)
var result = [Int: [Int: [MoodEntryModel]]]()
for entry in data {
let year = calendar.component(.year, from: entry.forDate)
let month = calendar.component(.month, from: entry.forDate)
if result[year] == nil {
result[year] = [:]
}
if result[year]![month] == nil {
result[year]![month] = []
}
result[year]![month]!.append(entry)
}
return result
}
}