import Foundation import CoreData /// Detects INTERNAL gaps in a source's snapshot history: two consecutive /// snapshots more than one calendar month apart, i.e. one or more calendar /// months between them have no data at all. /// /// This complements `ChartGapFill`, which only smooths the *display* by /// interpolating/carrying values across those empty months. The chart looks /// continuous, but real data is missing — this surfaces that so the user can /// fill it in. Read-only: it never mutates Core Data. enum SnapshotGapDetector { /// A single internal gap for one source. struct Gap: Identifiable, Equatable { let sourceId: UUID let sourceName: String /// The month of the snapshot before the gap (start of that month). let fromMonth: Date /// The month of the snapshot after the gap (start of that month). let toMonth: Date /// Number of calendar months with no data between `fromMonth` and `toMonth`. let missingMonths: Int /// The individual missing months (start-of-month dates), oldest first. let missingMonthDates: [Date] var id: String { "\(sourceId.uuidString)-\(fromMonth.timeIntervalSince1970)" } } /// Computes all internal gaps across the provided sources' snapshots. /// Only months strictly between two known snapshots count — a source's /// trailing "not updated in a while" is handled elsewhere (pending updates). static func detectGaps(sources: [InvestmentSource], snapshots: [Snapshot]) -> [Gap] { let calendar = Calendar.current let snapshotsBySource = Dictionary(grouping: snapshots) { $0.source?.id } var gaps: [Gap] = [] for source in sources { let sourceId = source.id guard let sourceSnapshots = snapshotsBySource[sourceId], sourceSnapshots.count >= 2 else { continue } // One value per calendar month (keep the latest in each), sorted ascending. var monthStarts: [Date] = sourceSnapshots .map { $0.date.startOfMonth } monthStarts = Array(Set(monthStarts)).sorted() guard monthStarts.count >= 2 else { continue } for i in 0..<(monthStarts.count - 1) { let from = monthStarts[i] let to = monthStarts[i + 1] let monthsApart = from.monthsBetween(to) guard monthsApart > 1 else { continue } var missingDates: [Date] = [] var cursor = from.adding(months: 1).startOfMonth while cursor < to { missingDates.append(cursor) cursor = cursor.adding(months: 1).startOfMonth } guard !missingDates.isEmpty else { continue } gaps.append(Gap( sourceId: sourceId, sourceName: source.name, fromMonth: from, toMonth: to, missingMonths: missingDates.count, missingMonthDates: missingDates )) } } // Most recent gaps first (by the month after the gap), then by source name. return gaps.sorted { if $0.toMonth != $1.toMonth { return $0.toMonth > $1.toMonth } return $0.sourceName < $1.sourceName } } /// Total count of missing months across all gaps. static func totalMissingMonths(_ gaps: [Gap]) -> Int { gaps.reduce(0) { $0 + $1.missingMonths } } }