From b17d8866a411d74e01a45d90f039e954a836e557 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Fri, 20 Feb 2026 22:56:30 +0100 Subject: [PATCH] Fix charts cutting off at Dec 2025: remove overly strict post-completion filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter excluded snapshots with snapshot.date > completionDate for that month. The bug: when a check-in for January is marked complete in February, the stored completion date is backdated to Jan 31 (min(endOfMonth, now)), but batch update snapshots were dated today (Feb 20). effectiveMonth maps Feb 20 → January, but Feb 20 > Jan 31 triggered the exclusion, hiding all 2026 data from charts. The check was redundant — monthDate <= lastCompleted already ensures only completed months are included. Removing it also correctly handles the existing snapshots already saved with a late date. Co-Authored-By: Claude Sonnet 4.6 --- .../ViewModels/ChartsViewModel.swift | 128 +++++++++++------- 1 file changed, 82 insertions(+), 46 deletions(-) diff --git a/PortfolioJournal/ViewModels/ChartsViewModel.swift b/PortfolioJournal/ViewModels/ChartsViewModel.swift index 4b0071d..e046919 100644 --- a/PortfolioJournal/ViewModels/ChartsViewModel.swift +++ b/PortfolioJournal/ViewModels/ChartsViewModel.swift @@ -177,6 +177,7 @@ class ChartsViewModel: ObservableObject { private var lastTimeRange: TimeRange? private var lastCategoryId: UUID? private var lastSourceId: UUID? + private var lastSourceIds: Set = [] private var lastAccountId: UUID? private var lastShowAllAccounts: Bool = true private var lastBreakdown: BreakdownMode = .category @@ -211,9 +212,11 @@ class ChartsViewModel: ObservableObject { // This prevents multiple rapid updates when switching between views Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount) .combineLatest($selectedSource, $selectedBreakdown, $showAllAccounts) + .combineLatest($selectedSourceIds) .debounce(for: .milliseconds(150), scheduler: DispatchQueue.main) - .sink { [weak self] combined, selectedSource, selectedBreakdown, showAll in + .sink { [weak self] outer, sourceIds in guard let self else { return } + let (combined, selectedSource, selectedBreakdown, showAll) = outer let (chartType, category, timeRange, _) = combined // Performance: Skip update if nothing meaningful changed @@ -222,6 +225,7 @@ class ChartsViewModel: ObservableObject { self.lastTimeRange != timeRange || self.lastCategoryId != category?.id || self.lastSourceId != selectedSource?.id || + self.lastSourceIds != sourceIds || self.lastAccountId != safeSelectedAccountId || self.lastShowAllAccounts != showAll || self.lastBreakdown != selectedBreakdown @@ -231,6 +235,7 @@ class ChartsViewModel: ObservableObject { self.lastTimeRange = timeRange self.lastCategoryId = category?.id self.lastSourceId = selectedSource?.id + self.lastSourceIds = sourceIds self.lastAccountId = safeSelectedAccountId self.lastShowAllAccounts = showAll self.lastBreakdown = selectedBreakdown @@ -384,7 +389,7 @@ class ChartsViewModel: ObservableObject { let filtered = allCategories.filter { categoriesWithData.contains($0.id) } switch chartType { - case .evolution, .prediction: + case .evolution, .prediction, .allocation, .performance: return filtered default: return [] @@ -421,6 +426,22 @@ class ChartsViewModel: ObservableObject { return selected.id } + /// Source-specific color palette (distinct from category colors, same order as Color.sourceColors) + private static let sourceColorHexes: [String] = [ + "#6366F1", "#F97316", "#06B6D4", "#EF4444", "#84CC16", "#EC4899", + "#14B8A6", "#F59E0B", "#8B5CF6", "#3B82F6", "#A855F7", "#10B981" + ] + + /// Assigns a unique color to each source based on its sorted position among all sources. + private func sourceColorMap(for sources: [InvestmentSource]) -> [UUID: String] { + let sorted = sources.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + var map: [UUID: String] = [:] + for (index, source) in sorted.enumerated() { + map[source.id] = Self.sourceColorHexes[index % Self.sourceColorHexes.count] + } + return map + } + private func categoriesForStackedChart( sources: [InvestmentSource], selectedCategory: Category? @@ -484,13 +505,25 @@ class ChartsViewModel: ObservableObject { return sampled } + // MARK: - Effective Month Mapping + + /// Maps a snapshot date to its effective check-in month. + /// Snapshots on days 1–20 are attributed to the previous month's check-in. + private func chartMonth(for snapshotDate: Date) -> DateComponents { + let effective = MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate) + return Calendar.current.dateComponents([.year, .month], from: effective) + } + + private func chartMonthStart(for snapshotDate: Date) -> Date { + MonthlyCheckInStore.effectiveMonth(for: snapshotDate, relativeTo: snapshotDate) + } + // 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 components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) - return DateComponents(year: components.year, month: components.month) + chartMonth(for: snapshot.date) } var series: [(date: Date, value: Decimal)] = [] @@ -577,12 +610,13 @@ class ChartsViewModel: ObservableObject { ) }.sorted { $0.value > $1.value } case .source: + let colorMap = sourceColorMap(for: sources) allocationData = sources.compactMap { source in guard source.latestValue > 0 else { return nil } return ( category: source.name, value: source.latestValue, - color: source.category?.colorHex ?? "#6B7280" + color: colorMap[source.id] ?? "#6B7280" ) }.sorted { $0.value > $1.value } } @@ -591,7 +625,7 @@ class ChartsViewModel: ObservableObject { private func calculateAllocationEvolutionData(from snapshots: [Snapshot]) { let sortedSnapshots = snapshots.sorted { $0.date < $1.date } let groupedByMonth = Dictionary(grouping: sortedSnapshots) { snapshot -> DateComponents in - Calendar.current.dateComponents([.year, .month], from: snapshot.date) + chartMonth(for: snapshot.date) } let sortedMonths = groupedByMonth.keys.sorted { @@ -600,7 +634,9 @@ class ChartsViewModel: ObservableObject { return d1 < d2 } - var result: [(date: Date, category: String, percentage: Double, color: String)] = [] + // First pass: compute totals per category across all months for stable ordering + var globalCategoryTotals: [String: (total: Decimal, color: String)] = [:] + var monthlyData: [(date: Date, categories: [String: (value: Decimal, color: String)])] = [] for monthKey in sortedMonths { guard let monthSnapshots = groupedByMonth[monthKey], @@ -625,14 +661,31 @@ class ChartsViewModel: ObservableObject { let colorHex = snapshot.source?.category?.colorHex ?? "#6B7280" let existing = categoryTotals[categoryName] ?? (value: 0, color: colorHex) categoryTotals[categoryName] = (value: existing.value + snapshot.decimalValue, color: colorHex) + + let globalExisting = globalCategoryTotals[categoryName] ?? (total: 0, color: colorHex) + globalCategoryTotals[categoryName] = (total: globalExisting.total + snapshot.decimalValue, color: colorHex) } let total = categoryTotals.values.reduce(Decimal.zero) { $0 + $1.value } guard total > 0 else { continue } + monthlyData.append((date: monthDate, categories: categoryTotals)) + } - for (category, info) in categoryTotals.sorted(by: { $0.value.value > $1.value.value }) { + // Stable category order based on overall totals (largest first) + let stableCategoryOrder = globalCategoryTotals + .sorted { $0.value.total > $1.value.total } + .map { $0.key } + + // Second pass: emit data points in stable order + var result: [(date: Date, category: String, percentage: Double, color: String)] = [] + for month in monthlyData { + let total = month.categories.values.reduce(Decimal.zero) { $0 + $1.value } + guard total > 0 else { continue } + + for category in stableCategoryOrder { + guard let info = month.categories[category] else { continue } let percentage = NSDecimalNumber(decimal: info.value / total * 100).doubleValue - result.append((date: monthDate, category: category, percentage: percentage, color: info.color)) + result.append((date: month.date, category: category, percentage: percentage, color: info.color)) } } @@ -648,8 +701,7 @@ class ChartsViewModel: ObservableObject { guard !sourceIds.isEmpty else { return [] } let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> DateComponents in - let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) - return DateComponents(year: components.year, month: components.month) + chartMonth(for: snapshot.date) } var completed: Set = [] @@ -683,16 +735,11 @@ class ChartsViewModel: ObservableObject { ) return snapshots.filter { snapshot in - let monthDate = snapshot.date.startOfMonth - if let completionDate = MonthlyCheckInStore.completionDate(for: monthDate), - snapshot.date > completionDate { - return false - } + let monthDate = chartMonthStart(for: snapshot.date) if monthDate <= lastCompleted { return true } - let components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) - let key = DateComponents(year: components.year, month: components.month) + let key = chartMonth(for: snapshot.date) return completedMonthsAfter.contains(key) } } @@ -742,6 +789,7 @@ class ChartsViewModel: ObservableObject { ) }.sorted { $0.cagr > $1.cagr } case .source: + let colorMap = sourceColorMap(for: sources) performanceData = sources.compactMap { source in guard let snapshots = snapshotsBySource[source.id], snapshots.count >= 2 else { return nil } let monthlyTotals = monthlyTotalsByMonthYear(from: snapshots) @@ -759,14 +807,14 @@ class ChartsViewModel: ObservableObject { return ( category: source.name, cagr: cagr, - color: source.category?.colorHex ?? "#6B7280" + color: colorMap[source.id] ?? "#6B7280" ) }.sorted { $0.cagr > $1.cagr } } } private func calculateContributionsData(from snapshots: [Snapshot]) { - let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth } + let grouped = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) } contributionsData = grouped.map { date, items in let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution } return (date: date, amount: total) @@ -830,7 +878,7 @@ class ChartsViewModel: ObservableObject { private func calculateCashflowData(from snapshots: [Snapshot]) { let monthlyTotals = monthlyTotals(from: snapshots) - let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth } + let contributionsByMonth = Dictionary(grouping: snapshots) { self.chartMonthStart(for: $0.date) } .mapValues { items in items.reduce(Decimal.zero) { $0 + $1.decimalContribution } } @@ -908,36 +956,25 @@ class ChartsViewModel: ObservableObject { } private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] { - let sortedSnapshots = snapshots.sorted { $0.date < $1.date } - let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted() - guard !months.isEmpty else { return [] } - - var snapshotsBySource: [UUID: [Snapshot]] = [:] - for snapshot in sortedSnapshots { - guard let sourceId = snapshot.source?.id else { continue } - snapshotsBySource[sourceId, default: []].append(snapshot) + let groupedByMonth = Dictionary(grouping: snapshots) { snapshot -> Date in + chartMonthStart(for: snapshot.date) } - var indices: [UUID: Int] = [:] + let months = groupedByMonth.keys.sorted() + guard !months.isEmpty else { return [] } + + var latestBySource: [UUID: Snapshot] = [:] var totals: [(date: Date, totalValue: Decimal)] = [] - for (index, month) in months.enumerated() { - let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture - var total: Decimal = 0 - - for (sourceId, sourceSnapshots) in snapshotsBySource { - var currentIndex = indices[sourceId] ?? 0 - var latest: Snapshot? - - while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth { - latest = sourceSnapshots[currentIndex] - currentIndex += 1 + for month in months { + if let monthSnapshots = groupedByMonth[month] { + for snapshot in monthSnapshots.sorted(by: { $0.date < $1.date }) { + guard let sourceId = snapshot.source?.id else { continue } + latestBySource[sourceId] = snapshot } - - indices[sourceId] = currentIndex - total += latest?.decimalValue ?? 0 } + let total = latestBySource.values.reduce(Decimal.zero) { $0 + $1.decimalValue } totals.append((date: month, totalValue: total)) } @@ -947,8 +984,7 @@ 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 components = Calendar.current.dateComponents([.year, .month], from: snapshot.date) - return DateComponents(year: components.year, month: components.month) + chartMonth(for: snapshot.date) } var totals: [(date: Date, totalValue: Decimal)] = []