diff --git a/PortfolioJournal/Models/CoreDataStack.swift b/PortfolioJournal/Models/CoreDataStack.swift index e4ab70b..5b1ec20 100644 --- a/PortfolioJournal/Models/CoreDataStack.swift +++ b/PortfolioJournal/Models/CoreDataStack.swift @@ -213,11 +213,12 @@ class CoreDataStack: ObservableObject { } DispatchQueue.main.async { self?.isLoaded = true - // Collapse any logical duplicates already sitting in the local store - // (per-device default accounts/categories piled up by earlier syncs). - // Runs once at launch so an existing mess is cleaned even if no new - // remote change arrives this session. - self?.cleanupLogicalDuplicates() + // NOTE: automatic logical dedup DISABLED. Deleting cascade-parent + // entities (Account/Category) and letting those deletes propagate via + // CloudKit can trigger the Cascade delete rule on OTHER devices before + // the child-reassignment transaction imports — cascade-wiping sources + // and snapshots. This caused total data loss across devices on build 68. + // Dedup must be redesigned to be CloudKit-safe before re-enabling. MonthlyCheckInStore.migrateIfNeeded() // Migrate device-local UserDefaults data into Core Data so it syncs via iCloud. MonthlyContributionStore.migrateIfNeeded(context: container.viewContext) @@ -506,14 +507,9 @@ class CoreDataStack: ObservableObject { if removed > 0 { print("[RemoteChanges] Removed \(removed) duplicate objects after CloudKit import") } - // Then collapse *logical* duplicates (same entity, different UUIDs) — - // e.g. per-device default accounts/categories, or a dataset that was - // seeded independently in two environments. UUID dedup above can't see - // these because the UUIDs differ. - let logical = self.cleanupLogicalDuplicates() - if logical > 0 { - print("[RemoteChanges] Removed \(logical) logical duplicate objects after CloudKit import") - } + // NOTE: automatic logical dedup DISABLED here on purpose — see the load + // block. Deleting cascade-parent entities and propagating those deletes + // via CloudKit cascade-wiped children on other devices (build 68 incident). // Notify repositories to re-fetch unconditionally. NotificationCenter.default.post(name: .cloudKitForceReload, object: nil) self.objectWillChange.send() diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index 94e6308..7f2bd51 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -735,17 +735,25 @@ class ChartsViewModel: ObservableObject { // MARK: - Chart Calculations private func calculateEvolutionData(from snapshots: [Snapshot]) { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in + let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in chartMonth(for: snapshot.date) } - var series: [(date: Date, value: Decimal)] = [] - series.reserveCapacity(groupedByMonth.count) + let sortedMonthKeys = groupedByMonth.keys.sorted { + (Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast) + } - for (key, monthSnapshots) in groupedByMonth { + var series: [(date: Date, value: Decimal)] = [] + series.reserveCapacity(sortedMonthKeys.count) + + // Forward-fill per source so a month's total includes every source's last + // known value, not only the sources updated that month (which would make the + // line dip and disagree with the portfolio total). See DashboardViewModel. + var currentValueBySource: [UUID: Decimal] = [:] + + for key in sortedMonthKeys { var latestBySource: [UUID: Snapshot] = [:] - for snapshot in monthSnapshots { + for snapshot in groupedByMonth[key] ?? [] { guard let sourceId = snapshot.source?.id else { continue } if let existing = latestBySource[sourceId] { if snapshot.date > existing.date { @@ -755,14 +763,15 @@ class ChartsViewModel: ObservableObject { latestBySource[sourceId] = snapshot } } + for (sourceId, snapshot) in latestBySource { + currentValueBySource[sourceId] = snapshot.decimalValue + } - let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue } + let total = currentValueBySource.values.reduce(Decimal.zero, +) let date = Calendar.current.date(from: key) ?? Date() series.append((date: date, value: total)) } - series.sort { $0.date < $1.date } - evolutionData = downsampleSeries(series, maxPoints: maxChartPoints) } @@ -852,12 +861,16 @@ class ChartsViewModel: ObservableObject { var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:] var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = [] + // Forward-filled across months: a source keeps its last known value in months + // where it wasn't updated, so category allocations don't distort when a single + // source is missing that month. + var sourceLatest: [UUID: Snapshot] = [:] + for monthKey in sortedMonths { guard let monthSnapshots = groupedByMonth[monthKey], let monthDate = Calendar.current.date(from: monthKey) else { continue } var categoryTotals: [String: (value: Decimal, color: String)] = [:] - var sourceLatest: [UUID: Snapshot] = [:] for snapshot in monthSnapshots { guard let sourceId = snapshot.source?.id else { continue } @@ -1541,10 +1554,15 @@ class ChartsViewModel: ObservableObject { let grouped = Dictionary(grouping: periodSnapshots) { snap -> DateComponents in Calendar.current.dateComponents([.year, .month], from: snap.date) } + let sortedKeys = grouped.keys.sorted { + (Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast) + } var monthlyTotalsArr: [(date: Date, value: Double)] = [] - for (key, snaps) in grouped { - var latestBySource: [UUID: Snapshot] = [:] - for snap in snaps { + // Forward-fill per source across the period so an un-updated source keeps + // its last value instead of dropping the month's total. + var latestBySource: [UUID: Snapshot] = [:] + for key in sortedKeys { + for snap in grouped[key] ?? [] { guard let sourceId = snap.source?.id else { continue } if let existing = latestBySource[sourceId] { if snap.date > existing.date { latestBySource[sourceId] = snap } @@ -1556,7 +1574,6 @@ class ChartsViewModel: ObservableObject { let date = Calendar.current.date(from: key) ?? Date() monthlyTotalsArr.append((date: date, value: total)) } - monthlyTotalsArr.sort { $0.date < $1.date } guard let firstValue = monthlyTotalsArr.first?.value, firstValue > 0 else { return nil } let points = monthlyTotalsArr.enumerated().map { index, item in @@ -1669,17 +1686,23 @@ class ChartsViewModel: ObservableObject { } private func monthlyTotalsByMonthYear(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in + let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in chartMonth(for: snapshot.date) } - var totals: [(date: Date, totalValue: Decimal)] = [] - totals.reserveCapacity(groupedByMonth.count) + let sortedMonthKeys = groupedByMonth.keys.sorted { + (Calendar.current.date(from: $0) ?? .distantPast) < (Calendar.current.date(from: $1) ?? .distantPast) + } - for (key, monthSnapshots) in groupedByMonth { - var latestBySource: [UUID: Snapshot] = [:] - for snapshot in monthSnapshots { + var totals: [(date: Date, totalValue: Decimal)] = [] + totals.reserveCapacity(sortedMonthKeys.count) + + // Forward-fill per source so each month's total reflects every source's last + // known value (matches monthlyTotals and the portfolio total). + var latestBySource: [UUID: Snapshot] = [:] + + for key in sortedMonthKeys { + for snapshot in groupedByMonth[key] ?? [] { guard let sourceId = snapshot.source?.id else { continue } if let existing = latestBySource[sourceId] { if snapshot.date > existing.date { @@ -1695,7 +1718,7 @@ class ChartsViewModel: ObservableObject { totals.append((date: date, totalValue: total)) } - return totals.sorted { $0.date < $1.date } + return totals } private func monthlyReturnSeries( diff --git a/PortfolioJournal/ViewModels/DashboardViewModel.swift b/PortfolioJournal/ViewModels/DashboardViewModel.swift index 681f64f..d0fce7e 100644 --- a/PortfolioJournal/ViewModels/DashboardViewModel.swift +++ b/PortfolioJournal/ViewModels/DashboardViewModel.swift @@ -283,21 +283,33 @@ class DashboardViewModel: ObservableObject { return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:]) } - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in - let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) + let calendar = Calendar.current + let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in + let components = calendar.dateComponents([.year, .month], from: snapshot.date) return DateComponents(year: components.year, month: components.month) } + let sortedMonthKeys = groupedByMonth.keys.sorted { + (calendar.date(from: $0) ?? .distantPast) < (calendar.date(from: $1) ?? .distantPast) + } + var evolution: [(date: Date, value: Decimal)] = [] - evolution.reserveCapacity(groupedByMonth.count) + evolution.reserveCapacity(sortedMonthKeys.count) var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = [] - series.reserveCapacity(groupedByMonth.count) + series.reserveCapacity(sortedMonthKeys.count) var categoryTotals: [UUID: Decimal] = [:] - for (key, monthSnapshots) in groupedByMonth { + // Forward-fill per source: carry each source's last known value into later + // months where it has no new snapshot, so a month's total reflects EVERY + // source's latest value (not only the ones updated that month). This keeps + // the most-recent point equal to the dashboard total, which sums each + // source's latest snapshot regardless of month. + var currentValueBySource: [UUID: Decimal] = [:] + var categoryBySource: [UUID: UUID] = [:] + + for key in sortedMonthKeys { var latestBySource: [UUID: Snapshot] = [:] - for snapshot in monthSnapshots { + for snapshot in groupedByMonth[key] ?? [] { guard let sourceId = snapshot.source?.id else { continue } if let existing = latestBySource[sourceId] { if snapshot.date > existing.date { @@ -307,27 +319,29 @@ class DashboardViewModel: ObservableObject { latestBySource[sourceId] = snapshot } } + // Apply this month's updates on top of the carried-forward state. + for (sourceId, snapshot) in latestBySource { + currentValueBySource[sourceId] = snapshot.decimalValue + if let categoryId = snapshot.source?.category?.id { + categoryBySource[sourceId] = categoryId + } + } var total: Decimal = 0 var valuesByCategory: [UUID: Decimal] = [:] - - for snapshot in latestBySource.values { - let value = snapshot.decimalValue + for (sourceId, value) in currentValueBySource { total += value - if let categoryId = snapshot.source?.category?.id { + if let categoryId = categoryBySource[sourceId] { valuesByCategory[categoryId, default: 0] += value categoryTotals[categoryId, default: 0] += value } } - let date = Calendar.current.date(from: key) ?? Date() + let date = calendar.date(from: key) ?? Date() evolution.append((date: date, value: total)) series.append((date: date, valuesByCategory: valuesByCategory)) } - evolution.sort { $0.date < $1.date } - series.sort { $0.date < $1.date } - return EvolutionSummary( evolutionData: evolution, categorySeries: series,