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)] = []