a8a9ad64f3
- Imports de CoreData que faltaban (MemberImportVisibility) en AccountsView, ImportDataView, ChartsContainerView y AddSourceView. - Aislamiento MainActor: IsolatedDefaultValues en el target de la app, LinearGradient @MainActor, WatchSyncMessage nonisolated, closures de ReviewPromptService/MonthlyCheckInStore/AppIntents. - Warnings de valores sin usar (SnapshotGapDetector, DashboardLayoutStore, OnboardingView, GoalEditorView, CoreDataStack, SettingsViewModel). - foregroundColor→foregroundStyle, cornerRadius→clipShape, navigationBarLeading/Trailing→topBarLeading/Trailing, Task.sleep(nanoseconds:)→sleep(for:), NavigationLink(isActive:)→ navigationDestination(isPresented:). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
87 lines
3.5 KiB
Swift
87 lines
3.5 KiB
Swift
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 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 }
|
|
}
|
|
}
|